Skip to content
← all writing

My Cost Dashboard Was Lying: Two Bugs That Hid 57% of My Hermes Spend

  • hermes
  • cost-tracking
  • token-logger
  • pricing
  • observability
My Cost Dashboard Was Lying: Two Bugs That Hid 57% of My Hermes Spend

I track what Hermes Agent costs by matching its log files against a live pricing cache. On 2026-10-04 the log held 970 calls. The total came to $0.2527.

That number was wrong. The real total was $0.5852. I was missing 57% of my own cost. I would never have noticed on my own, because a low number looks the same whether it's right or wrong.

I found two bugs. Neither one threw an error. Both gave me numbers that looked real.

Looking at all those zeros

When 915 out of 970 calls show $0, most of them are probably correct. Some models are free. Some run on my own computer. A tool that says $0 for a free model is working the way it should.

So before I changed anything, I sorted the zeros by provider and model:

Provider Model Zero rows Verdict
custom:openrouter stealth/space-bunny-alpha 410/410 Genuinely free
openrouter stealth/space-bunny-alpha 225/225 Genuinely free
lmstudio frognano-4b-2609@q4_k_s 220/220 Local inference
custom:openrouter mistralai/mistral-large-2512 60/60 Bug - $0.50/M in, $1.50/M out
All - 855 / 915 Correct at zero

855 of those zeros were right. 60 were a bug.

Here's the part that confused me. The price I needed to fix those 60 rows was already sitting in the cache. I called the lookup function by hand with ("custom:openrouter", "mistralai/mistral-large-2512") and it gave me a price right away: $0.50/M. So the lookup worked. The data was there and up to date.

That meant the break was somewhere between two steps: "the cache has the price" and "the row in the CSV gets the price."

Bug one: the prefix it never stripped

Hermes can send a provider through a custom gateway. When you do that, the config saves the provider name as custom:<name>. So a call that should show up as OpenRouter gets logged as custom:openrouter.

The pricing cache uses plain provider names. When the tool looked for custom:openrouter, there was no key with that name. It returned nothing. The code read that nothing as "no price for this one" and wrote $0.

The fix is to cut off the prefix and try again:

if provider.startswith("custom:"):
    provider = provider.split(":", 1)[1]

This one mattered more than the dollar amount suggests. It was breaking cost tracking on live calls, not just on the old logs I was fixing. Every routed call was hitting $0 at the moment it ran.

It was also the more annoying bug to track down. The plugin kept saying everything was fine the whole time. _find_rate worked perfectly when I called it with the right name. Nothing in the code ever checked that the name the logger passed in matched the name the cache had saved.

Bug two: it couldn't see today's file

The job that adds prices looks for *.csv.gz files in the log folder.

The logger writes a plain .csv file for the current day. It only compresses that file later, when it archives the day. So the file the job was looking for did not exist yet. It showed up the next day, by which point the job had already moved on.

Same-day rows were not just sometimes missed. They were impossible to reach. Every day, all of them.

# before - matches only the archived form
files = sorted(log_dir.glob("*.csv.gz"))

# after - matches both
files = sorted(log_dir.glob("*.csv*.gz")) + sorted(log_dir.glob("*.csv"))

Saving the results needed care too. If you write a plain .csv back as gzipped, the nightly archiver will not find the file it expects. So the new code keeps whichever format it read.

What the fix was worth

On 2026-10-04, 60 rows went from $0 to real prices. The day total went from $0.2527 to $0.5852.

The better way to read that is not "my costs doubled." It is "the old number was too low." Anyone who saw the old number would have thought that was a cheap day.

The same problem behind both

Both bugs worked the same way. The lookup returned nothing, and nothing looked the same as a real answer. The plugin could not tell "this provider has no price" apart from "I used the wrong name." So it called both of them zero.

A cost tool that drops to zero when it gets confused is worse than no cost tool, because a zero still looks like data. If your cost suddenly drops and you did not change what you ran, check whether it dropped to exactly zero. A real drop goes to a smaller number. Zeros are where failed lookups hide.

One thing would have caught both bugs right away: a counter for rows with no price, split out by why. Right now the plugin says "220 rows have no matching rate in cache" as one number. All 220 of those are local models that really have no rate. If the number had been 280, I would have spotted the problem on the first run.

Where the plugin stands

Both bugs are fixed in hermes-model-pricing v1.2.0. The same release adds pricing from the Nous Portal, which needs no API key and covers 410 models. It has real first-party prices for Anthropic, OpenAI, DeepSeek, Qwen, and GLM.

The README lists three gaps I did not paper over. The plugin reads cache_write_cost but does not use it in the math yet, so runs that lean hard on cache writes read a little low. The Groq scraper has not been checked since Groq changed their pricing page. And NVIDIA NIM sends back model names with no prices attached, so anything routed through it records $0. That last one is bug one all over again, just from a different cause.

Termagotchi
_

Ryan Underdown

Autodidact. Rarely listens to advice.

Follow on X @catamarammed or GitHub @underdown