An AI plant scan in LeafRanger costs about $0.004. We know because every AI call the app makes hands its token usage to a meter, which prices it in whole micro-dollars and adds it to one row per user per day. That measurement set the plans: 30 free scans a day is at most about 12 cents per user, while a chat turn on the larger model costs about $0.01, so chatting with Herb became the paid perk.
This article goes deeper than the LeafRanger case study: how the meter is wired, why a scan cost forty times what our own estimate said, how the router decides which model answers, and what we would change.
Why meter tokens instead of counting calls?
Because calls are not equal. A chat turn on the larger model costs about 25 times one on the small model, and a count of requests hides that. So we keep two units. Users see counts: scans and messages, which they can understand and plan around. Internally we record tokens in, tokens out and cost.
Before the meter existed, our AI wrapper threw the usage block of every response away, and the admin dashboard's AI-cost card showed a hard-coded zero "until the metering lands". Nobody could say what a scan cost. We built the meter on 1 July 2026, before launch, rather than waiting for the first bill.
How is every vision and chat call metered?
There is one place in the backend that talks to the AI provider: a thin wrapper with two helpers, one for chat and one for vision with a JSON reply. Both take an optional onUsage callback. After a successful response, the wrapper passes the response's token counts and the model name to it. Return shapes did not change, so callers that do not meter needed no edits, and a failure inside the callback is caught so metering can never break a user's request.
The recorder prices the tokens and adds them to the day's row:
const PRICES = {
[SMALL_MODEL]: { inPerM: SMALL_IN, outPerM: SMALL_OUT }, // USD per 1M tokens, from the price page
[LARGE_MODEL]: { inPerM: LARGE_IN, outPerM: LARGE_OUT },
};
const costMicros = Math.round(tokensIn * p.inPerM + tokensOut * p.outPerM);
await AiUsage.updateOne(
{ userId: userId || null, day: dayKey() }, // "YYYY-MM-DD", UTC
{ $inc: { tokensIn, tokensOut, costMicros } },
{ upsert: true },
);
Dollars per million tokens times tokens is exactly micro-dollars, so the arithmetic stays in integers and a month of small additions never drifts. A unique index on user and day makes the upsert safe when two requests land at once. Background work with no user to bill, such as researching a species the first time someone adds it, is recorded with a null user: the system bucket. Platform spend is visible without being charged to anyone's quota.
During verification, one live chat turn recorded 1,209 tokens in, 251 out and 332 micro-dollars, which is $0.000332 for one turn on the small model.
What does one scan actually cost, and why was our estimate wrong?
Telemetry put a vision scan at about $0.004, at roughly 25,800 tokens per photo. Our notes put the cause in one line: the small model counts an image as about 33 times as many tokens as the larger model does. Its per-token price is low; its token count for a photo is not. At its input price, 25,800 tokens comes to $0.0039.
A later design note in our own repository estimated an identify call at about $0.0001, assuming a low-detail image of about 100 tokens. That estimate was off by a factor of about forty, and it was written by people who already had the meter. The lesson we took: a price page tells you the rate, the meter tells you the bill. Every pricing decision below uses the measured figure.
For comparison, the same telemetry put a chat turn on the small model at about $0.0004 and a turn on the larger model at about $0.01.
Why do vision calls run on the small model, while only Herb chooses per turn?
The perception calls, reading the light in a room, identifying a plant, diagnosing a leaf and reading soil, each return a small, strict JSON object: a light class and a confidence, a ranked list of candidates, an issue with a severity. The answer space is narrow and our own code checks what comes back: identification candidates are resolved to species records in our catalogue before anything is shown. For that job the small model is vision-capable and cheap, and the model is a single environment setting.
Herb, the chat assistant, is different. A user describing a dying plant in three sentences needs the stronger model; "thanks!" does not. So the chat router picks a model for each turn with a fixed set of rules, logged with the reason. A turn goes to the larger model when it has:
- a photo attached;
- more than 220 characters;
- two or more question marks;
- diagnostic words such as yellow, droop, rot, pest or dying;
- twelve or more turns of history behind it.
Anything else goes to the small model. If the chosen model fails, the turn falls back to the small model, then to a fixed answer, so a model failure never reaches the user as an error. History is never cleared when the model changes between turns; the new model gets a short note saying the thread above is complete and authoritative.
A photo turn in chat makes two calls: an identification, so the answer is written with the right species card in hand, and the chat turn itself. Both are metered to the user. The comment in the code puts it plainly: hiding the second call would make the quota a lie.
How were the per-plan daily caps set?
The first caps were approved on 1 July 2026, before the meter had recorded anything. Three days of telemetry showed scans were cheap and chat on the larger model was the real cost, and on 4 July we re-tuned them:
| Plan | Scans a day (1 July → 4 July) | Herb messages a day (1 July → 4 July) | Model routing |
|---|---|---|---|
| Hobbyist (free) | 5 → 30 | 10 → 0 | small model only |
| Gardener | 25 → 50 | 50 → 50 | per turn |
| Botanist | 100 → 150 | 200 → 200 | per turn |
Scans became the generous free hook: 30 a day costs at most about $0.12 per user even if every one is used. Chat on the larger model is the real cost, so it moved behind the paid plans. The Hobbyist pin to the small model stays in the router as a guard, even though a cap of zero means it is not reached today.
Caps bound the worst case, not the average. A Gardener who used all 50 scans and sent all 50 messages to the larger model every day would cost about $0.70 a day, more in a month than the plan's $4.99 reference price. That is arithmetic on the measured figures, not usage data; the 30-day cost card is where real spend will show up.
A cap is enforced by reading today's count before the call and adding one after the call returns. At the cap, the API answers 429 with an upgrade message built from the same tier table, naming the next plan's limit, so the copy cannot quote a stale number. Calls that never reach the AI provider, a scan with no photo or a server with no key, are neither counted nor charged. The quotas exist to guard real spend.
What does the 30-day AI-cost card show?
The admin console runs on a separate web backend that shares the same MongoDB. It reads the usage collection through a read-only mirror model, sums the cost of every row from the last 30 days, and divides by 10,000 to turn micro-dollars into cents. We proved the aggregation with a synthetic $5 row, which showed as 500 cents, and then removed it. Development spend at the time, $0.0042, correctly rounded to zero cents.
One bug is worth telling. The card used to inherit the platform's currency field, which says EUR, so a dollar amount was shown with a euro sign beside the marketplace's euro figures. Model providers bill in dollars, so the card now carries its own currency.
These are development numbers. LeafRanger is not in an app store yet and paid plans are not switched on, so no customer has generated a bill. The meter is in place for when they do.
What we'd do again, and what we'd do differently
Again:
- Meter from the first real call, through a single wrapper, with integer micro-dollars and a system bucket for platform work.
- Build limit messages from the tier table, never from copied numbers.
- Let the measured cost, not the price page, decide what is free.
Differently:
- Price unknown models honestly. A model missing from the price table is priced as the small model, the cheapest, so setting a pricier model by environment variable would under-report spend. On a later project we made an unpriced model show "no price on file" instead.
- Reserve the quota atomically, and give it back on failure. Because the count is read before the call and incremented after, two requests fired together at the cap can both pass. And a real identification that fails returns no candidates but still uses one of the day's scans. Both leaks are small; a single conditional update that reserves the slot, released when the call fails, would close them.
- Say whose midnight. Quotas reset at midnight UTC, which is 5:30 in the morning in India, while the message says "midnight".
- Test the image detail setting. Our vision calls send the photo without a detail setting. A light-reading call may well be as accurate on a low-detail image at a fraction of the tokens; we have not measured it, and we would not change it without running the same photos both ways.
For how we approach AI features where every call has a price, see our AI app development page. The meter's one-row-per-day write is MongoDB's upsert with $inc; the prices in our table were set in July 2026 and should be re-checked against an invoice.
