Measuring what an agent costs is an accounting problem

I had been working with AI agents for months without knowing what they cost. Not for lack of data: for excess of it.
Every tool leaves its trail on disk. Claude Code writes a .jsonl per session. Cursor keeps a one-gigabyte SQLite database. Codex writes daily rollouts. OpenCode scatters a JSON file per entity. All four record different things under different names, and none of them answers the simple question: how much.
So I built the reader. The interesting part was not the AI —there is none: it is files and SQL— but that measuring usage turned out to be the same old problem. The four traps I fell into are the ones anyone hits when counting money.
Trap 1: a cumulative total must not be summed
Codex writes a token_count event per turn, carrying total_token_usage with its input_tokens, output_tokens and so on. The natural reading is to walk the events and add them up.
That is wrong. The total is cumulative for the session, not for the turn. Summing it multiplies usage by the number of turns.
turn 1 → total_token_usage.input = 1,000
turn 2 → total_token_usage.input = 3,000 (not 2,000: it is cumulative)
summing → 4,000 ✗
maximum → 3,000 ✓
This is not hypothetical: it is a real bug that hit ccusage, with a reported 91× inflation. A counter that is wrong by a factor of 91 is not caught by looking at the final figure —it looks big, and AI bills are big— but by comparing it against another source.
The ledger parallel is exact: a materialised balance is not added to the entries that produced it. It is their result, not another addend.
Trap 2: reported input already includes the cache
The second mistake is quieter. Providers report input_tokens and, separately, cached_input_tokens. It is tempting to add them to get "how many tokens went in".
The cached part is already inside the input. Adding them double-counts the cheapest part, and inflates it exactly where the volume is: in my own data, cache is 98 % of all tokens read. A 2 % error in the wrong place does not matter; a double count across 98 % changes the whole result.
// reported input already includes the cached part: subtract it or count it twice
input: Math.max(0, reported.input_tokens - reported.cached_input_tokens),
Trap 3: cache does not cost the same across providers
This is where a "generic" formula breaks on its own.
- Anthropic charges a premium for cache writes —1.25× at five minutes, 2× at one hour— and 0.1× for reads.
- OpenAI does not charge extra for writes: they bill at the normal input rate. Only reads are cheaper.
A single formula is always wrong for one of them. And not innocently: if your workload is dominated by cache writes, the error runs towards underestimating.
That is why rates live in a JSON file, never in code, and each vendor carries its verification date and a link to the page it came from. A price in the code is a price nobody will ever re-check.
Trap 4: the filter must cut messages, not sessions
I found this one comparing the export against the screen that produced it, and it is the easiest to get wrong.
You filter "1st to 10th of August". The natural implementation finds the sessions in that range and sums their usage. But a session that starts on the 5th and ends on the 17th touches the range, so it comes in whole, with the fifteen days of work you did not ask for.
In my database, with the same filter:
messages INSIDE the range 33,154
messages from sessions TOUCHING it 47,766 → +65 % of attributed cost
The export said one number and the screen's own header said another. Both came from the same codebase, written under two different criteria weeks apart.
The fix is to cut per message and recompute each row's dates from the minimum and maximum that remain inside the window. And because this is exactly the kind of thing that breaks again, the test pinning it does not check the implementation: it checks the invariant.
// a session with two messages inside ($3) and one outside ($40)
expect(total.cost).toBeCloseTo(3); // not 43
expect(sum(exportedRows)).toBeCloseTo(total.cost); // export == screen
A test that says "the export must sum to exactly what the screen shows" survives someone rewriting the query. One that checks the WHERE clause does not.
The rule holding up everything else: never invent a number
It is the one rule I do not negotiate, and the uncomfortable decisions follow from it.
If a rate is not verified against the vendor's own page, the model is flagged UNVERIFIED, its cost counts as zero, and the screen says so. It looks worse than estimating. It is also the only honest option: an invented price propagates into every aggregate and nobody questions it again.
Cursor records no per-request tokens —they all come through as zero— so its figures stay out of the money. It gets its own tab, measuring what it does store: what share of committed code was written by AI, per branch and per commit. And no field ties a process id to its session, so the screen reports how many processes are alive and, separately, which sessions wrote in the last ten minutes. It does not join them.
An empty cell is information. A plausible fabrication is a bug nobody will catch.
Read-only, and genuinely so
The meter reads the tools' own folders. That forces a second rule: never write into them, and not as a README promise but as something the code prevents.
Every access goes through a function that opens with O_RDONLY; any attempt to write into a foreign root throws, and there is a test for it. A denylist prevents even reading credentials or .env files.
The interesting case is Cursor: it keeps its SQLite databases in WAL mode and writes to them while the app is open. Opening them read-only is not enough — SQLite would want to create a -shm file next to the original, and that is already writing into someone else's folder. They are copied before being read. The test builds a toy WAL database and verifies the source directory is byte-for-byte identical afterwards.
What I take away
None of this is specific to AI. They are the same four questions as any system that counts money: where did the number come from, is it cumulative or incremental, am I counting something twice, and what happens when the data is missing.
The difference is that in a ledger someone already asked them for you. In a new domain you have to remember to ask them yourself.
The meter is local, has no dependencies, sends nothing anywhere, and is published under MIT: github.com/ASanchezT85/agent-engine.
Comments
No comments yet. Be the first.