Track LLM spend per feature by parsing the bill your CLI already prints
The app I maintain writes chapters — a life told as a book, or a novel — and every chapter is one call to a language model. That call does not go out over HTTP from my process. It is a subprocess: a CLI is invoked with the prompt on stdin, a 180-second timeout, and the text comes back in a file. That detail is what made the accounting interesting.
What I wanted was ordinary: spend per book, and spend per call site. Which feature is expensive — the chapter itself, the once-per-book preparation pass, the retry path? Without that, every cost conversation is a guess.
What I did not want was to pay for the measurement: no second call to ask a model what the first one cost, no billing API poll. The answer was already on the floor. The CLI prints a line at the end of every run that reads tokens used: N, and we were discarding it with the rest of stdout. The whole feature is: stop throwing away a stream you have already paid for.
How do you attribute a cost without touching the function signature?
The obvious design is a parameter. write_with_luna(prompt, effort, book_id, station). I did not do that, for a reason that has nothing to do with taste.
That function is a seam. The test suite replaces it wholesale — roughly fifty fake pens, written as lambda p: … and lambda p, effort="low": …, so that running the tests never spends a cent on a real model. Add a mandatory argument to the real function and every one of those lambdas has to grow it too. Miss one, and a test fails — not because the thing it measures broke, but because of bookkeeping I bolted on. A test that fails for a reason unrelated to its subject is worse than no test.
So the attribution travels beside the call instead of inside it:
_attribution: ContextVar[tuple[int | None, str | None]] = ContextVar(
"attribution_plume", default=(None, None)
)
def attribute(livre_id: int | None, poste: str | None) -> None:
_attribution.set((livre_id, poste))
(Column names in this codebase are French: livre_id is the book, poste the call site.) A call site now reads llm.attribute(book_id, "chapitre") on the line before the call, and nothing about the signature changes. The fake pens keep working, untouched.
A ContextVar, not a module-level global. Writing happens in background threads — the browser request returns in a fraction of a second and the model call continues behind it — so two authors can easily be mid-chapter at once. A global would let one thread's attribution overwrite another's; a context variable gives each thread its own, and two simultaneous authors cannot steal each other's.
Why the attribution is consumed rather than sticky
Reading it also clears it:
def _take_attribution() -> tuple[int | None, str | None]:
current = _attribution.get()
_attribution.set((None, None))
return current
This is the decision I would defend hardest, because it looks like a bug generator. If a call site forgets its attribute() line, the row lands with a NULL book id.
Good. A NULL is visible — it shows up in the ops dashboard as an unattributed call, and someone goes and finds the missing line. The alternative, an attribution that persists until overwritten, quietly charges the forgotten call to whatever book happened to be written last. Nothing errors. Nothing looks wrong. The number is simply not true, and it never says so.
An instrument that fails loudly is repairable. An instrument that lies politely is worse than no instrument, because you will make decisions on it. Loud NULL beats quiet wrong.
Parsing a line written for humans
The summary line was designed to be read by a person, not consumed by a program, so the parser has to be tolerant in the right places and strict in the right places:
_TOKENS_USED = re.compile(
r"tokens[ \t]+used[ \t]*:?[ \t]*([0-9][0-9.,\u202f\u00a0\u2009 ]*)",
re.IGNORECASE,
)
(The three invisible characters in that class are written as escapes here; in the file they are literal.) Three choices in one line.
The character class stays on a single line. No \n, no \r. If the words appear without a number after them — a wrapped line, a truncated buffer — a greedier class would happily walk to the next line and adopt some unrelated integer as your token count. You would get a number. It would be someone else's number.
Thousands separators are assumed. Comma, dot, ordinary space, and the three Unicode spaces a locale-aware formatter reaches for: no-break U+00A0, thin U+2009, narrow no-break U+202F. Everything non-digit is then stripped before int(). The CLI's formatting is not part of my contract with it, and I would rather absorb an unexpected separator than crash on one.
The last occurrence wins.
found = _TOKENS_USED.findall(text)
if not found:
return None
digits = re.sub(r"[^0-9]", "", found[-1])
return int(digits) if digits else None
The CLI can chatter mid-run. Only the closing summary is the bill, so findall and take the tail rather than search and take the head.
And the whole thing is a pure function of a string: parse_tokens_used can be tested exhaustively — missing line, zero, separators, junk on either side — without ever launching the model. When the expensive part of a system sits behind a process boundary, find the pure function hiding inside it and put your tests there.
One related subtlety, since the CLI has moved this line between streams across versions: stdout is read first and stderr is the fallback, and the check is is None rather than a truthiness test. A reported total of zero is a value, not an absence.
read = parse_tokens_used(decoded(stdout))
return read if read is not None else parse_tokens_used(decoded(stderr))
The logger is not allowed to fail
Every path into the database is wrapped:
try:
with db() as conn:
conn.execute(
"INSERT INTO plume_appels (livre_id, poste, effort, statut, tokens, duree_ms, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, datetime('now'))",
(livre_id, poste, effort, statut, tokens, duree_ms),
)
except Exception as e:
print(f"plume : appel {poste} (livre {livre_id}) non compté ({e})")
A blanket except Exception is normally a smell. Here it is the specification. A read-only database, a lock held a beat too long, an older copy of the database that predates the table — each of those is a real thing that happens on a small server, and none of them is worth a user's chapter.
Note where this sits in the sequence: by the time the row is written, the text has already been generated and the tokens have already been billed. Losing the row costs me a line in a report. Raising here would cost an author the chapter they just paid for. Failures are recorded too, with a status of echec — a run that dies partway through can still have burned tokens, and an invisible expense is the only kind you never fix — but that write is inside the same never-throws guard.
The bug that was not a bug
The accounting code went live at 11:50 with a service restart. At 13:35 I checked the table. Empty. And yet a chapter had definitely been generated at 11:19 — I could see it in the app. So: parser silently returning None? Attribution never set? The swallow-everything logger swallowing something real?
None of the above. The 11:19 chapter was produced by the previous process, started at 08:39 — hours before the code that writes those rows existed. Nothing to fix. The instrument had not run yet.
Long-lived Python processes make this trap easy to fall into: your editor shows the new code, your repository shows the commit, and the thing actually serving requests is an image of a file that has since changed on disk. The check takes one command — compare the process start time against the commit time — and it should come before any hypothesis about your own instrumentation being broken. The first genuine rows are expected from tonight's scheduled runs.
Measure the stream you have already paid for. Before adding a call, a poll or a dependency to find out what something costs, look at what your tooling already prints and throws away. Attribute out-of-band so the measured function's signature stays untouched, consume the attribution so gaps show up as NULLs instead of plausible lies, and make the recorder incapable of failing — accounting is an instrument, not a feature.
The app being measured is Fablier — three questions each evening, and an AI writes your life as a book, one chapter per night. The cost constraint behind all of this is simple and permanent: never a more expensive model. Which is only enforceable if you know what the current one costs, per book, per feature.
Previously: half the Googlebot in your logs isn’t Google, pixel-golden testing a redesign in headless chromium, read-aloud with browser speechSynthesis, and nginx 499: the request your user didn’t wait for.