2026-09-07 The Catalog Had a Second Half
What I set out to do
Answer a small question: which image model should I be using? It turned into finding ~40 models that had been quietly reachable, unlisted, and free on paper the whole time.
What I actually did
Started by pricing the routable image models honestly. The default is
openrouter/google/gemini-3.1-flash-image, and rather than guess at its cost I
read it off the telemetry built yesterday: 7 renders, all 1120 image output
tokens, **0.134` off a single span and taken it for the per-render rate.
It was two renders’ worth.
Then Adam pasted OpenRouter’s image catalog: 50 models. Our proxy listed 9. I said the other 41 weren’t routable.
That was wrong, and one command proved it. I posted a flux.2-klein-4b
render straight at the proxy expecting a rejection, and got a 1024x768 PNG back.
The openrouter/* wildcard had been forwarding them all along. I had inferred
“not routable” from reading LiteLLM’s transformation code, which posts image
generation to /chat/completions, and concluded a non-chat model couldn’t work.
The inference was clean and the conclusion was false.
The real shape, once I looked: OpenRouter keeps two disjoint catalogs.
/api/v1/models has 428 chat models. /api/v1/images/models has 50. Gemini’s
image models and gpt-5-image are in both, because they’re chat models whose
output modality includes images. flux, recraft, seedream, qwen-image, riverflow,
grok-imagine, krea and mai-image are in the second only. Everything we own reads
the first.
What was striking
The listing gap was the harmless half. The serious half was the same models
being absent from litellm.model_cost. The flux span landed in SigNoz carrying
every attribute yesterday’s work put there:
gen_ai.output.type image ok
gen_ai.usage.image.output_tokens 3072 ok
gen_ai.provider.name openrouter ok
gen_ai.cost.total_cost null <-
Real spend on a model that was trivially easy to reach and impossible to see. The exact failure 2026-09-07 Image Cost Was in the Header and Nowhere Else closed, walking back in through a different door: not a missing attribute this time, a missing catalog.
Two consumers read a catalog, and I only found the second one by deploying
the first fix. I patched the enrichment plugin, switched, and the models were
priced but /v1/models still said 441. The wildcard expansion goes through
OpenrouterModelInfo.get_models, which is itself one of our patches, and it
fetches the chat listing independently. Fixing one half and calling it done
would have been very easy: the cost was fixed, which was the part I cared about,
and the visible symptom would have stayed exactly as reported.
Three billing units, and the two obvious cost-map keys are never read.
OpenRouter bills image models per token (Google, OpenAI), per megapixel (flux),
or per image (recraft, seedream). default_image_cost_calculator consults
input_cost_per_image and input_cost_per_pixel — the output_cost_per_*
names that look like the natural home for an output price appear nowhere in it.
Then measurement overturned my own design rationale. I’d written that the
megapixel scaling was load-bearing. It mostly isn’t: once a model is in the cost
map at all, LiteLLM prefers OpenRouter’s reported cost and the flat calculator
never runs. Two renders requesting 512x512 and 1024x768 both came back at
0.017, matching neither the calculator’s output for those sizes (0.011) nor the advertised rate against the delivered image ($0.0516). A figure
that ignores the requested size is the provider’s number, not a local one.
Registration was the unblocking act; the unit mapping is the fallback.
The corollary is the sharp bit: the requested size is not the delivered size. Every flux render returned 1920x1920 regardless of what I asked for. Any locally computed per-pixel figure would have priced an image that was never generated. Provider-reported cost winning is the better outcome, and I only know that because I ran the same prompt at two sizes instead of accepting the first plausible number.
(Both halves of that paragraph survived the day, but only after I spent three turns arguing myself out of the 1920x1920 figure and back into it. See the tiered-pricing section below. The flat calculator turned out to be less safe than this reads, not more: it overbills a large render 3.1x.)
And there was a third consumer, which only surfaced because Adam asked a
plain question. “Does this show as part of list images?” The MCP server’s
list_models(capability="image") filtered on mode == "image_generation", and
a dual-catalog model keeps mode = "chat" — the chat pass runs first and the
image pass only fills missing keys, which I designed that way on purpose and
even wrote a test asserting. So Nano Banana Pro, Nano Banana 2 Lite, both
previews, Nano Banana 1 and all three gpt-5-image models were hidden from the
image catalog while staying perfectly routable.
The detail that made it invisible: gemini-3.1-flash-image escaped by
accident, because it has an explicit model_list entry stamping
mode=image_generation. The configured default looked fine while every
alternative to it was hidden. Two fixes and two deployments had gone past
this without a hint. The fix is to ask what a model can do rather than what
its primary mode is — a non-null output_cost_per_image_token — and the image
catalog went 42 to 50, matching OpenRouter exactly.
Three consumers, each found only by fixing the previous one. Nothing about the first fix suggested there was a second; nothing about the second suggested a third. The thing that kept surfacing them was checking the user-visible surface after each deploy instead of trusting that the layer I’d changed was the only one that read a catalog.
A test caught a latency bug I’d have shipped. The image pass makes one request per model, and an existing test started hitting the live endpoint: 0.05s to 16s. Same class as yesterday’s Drive-poll bug. The fix was a fixture that fails any unit test attempting a network fetch, which is a better guard than the one-off stub yesterday got.
A fourth consumer, and this one I nearly misdiagnosed. Running
list_models after the restart, the per-image and per-megapixel families all
read $0. I said flatly that this was “not a display artifact” and that flux
was unpriced. Then I checked the one thing that settles it: input_cost_per_pixel
is absent from all 112 keys of every record the endpoint serves, including
recraft and gemini, which are priced correctly. A field missing from the models
that work is a schema fact, not a bug. /model/info serves ModelInfoBase,
which has no input_cost_per_pixel at all.
Running the enrichment plugin’s own translation against live OpenRouter data
confirmed it produces input_cost_per_pixel: 3e-08 for flux.2-pro exactly as
designed. The plugin was never wrong; my reading of a null was.
The real bug was smaller and still worth fixing: the MCP catalog projected only
token-shaped cost fields, so 26 models billed per image reported nothing. Those
are the expensive ones. Riverflow at $0.13 an image costs more per render than
Nano Banana Pro, and both displayed as free next to a Gemini model showing a
per-token rate. A cost display that is wrong only on the expensive half
inverts the ordering it exists to provide — worse than showing no price at
all, because it looks like an answer. Carrying input_cost_per_image fixed 26
of the 50; the four flux models cannot be fixed from this endpoint, and
muse-image and the three krea-2-* publish no pricing upstream at all.
So: four consumers, not three. The pattern held one layer further than I expected each time, and the thing that keeps catching it is looking at the user-visible output after every deploy rather than trusting the layer I changed. The thing that nearly broke it this time was reading a null as evidence before finding a control.
Adam asked whether I was sure the eight were really unreachable, and I was not. I had checked one endpoint and generalised from it. Checking the rest changed two of my three claims.
/model_group/info does declare input_cost_per_pixel, so “the API cannot
express it” was simply false. It returns null there — but it also returns null
for recraft’s input_cost_per_image, which /model/info reports as 0.08, so
that route is an unreliable projection rather than evidence of anything. And
the field is declared on CustomPricingLiteLLMParams, the deployment-level
custom-pricing block, not on ModelInfo as I had said. It can only be pinned
per deployment, which one openrouter/* wildcard cannot do per model.
The thing I should have done first, and eventually did, was test the claim
positively instead of arguing from schemas: run the enrichment refresh and look
at the map. flux.2-pro goes from ABSENT to {'input_cost_per_pixel': 3e-08}.
The rate is there. Every route that could show it either omits the field or
fails to populate it, and /cost/estimate is token-only.
For muse-image and the krea models the answer went the other way and held:
endpoints: [] and pricing: [] respectively, no pricing in the listing, and
absent from the chat catalog. OpenRouter publishes no price for them at all.
The lesson is about nulls, and it is sharper than the one I wrote yesterday. Three models in this session showed a null cost field and each null meant something different: a schema that lacks the field, a route that fails to fill it, and a price that genuinely does not exist. I read the first as the third, then corrected to the first, and only a control — a model known to be priced, checked through the same route — told them apart. “A null result needs a control” is not just about experiments. It applies to every field I read off an API and treat as an answer.
Then Adam asked whether flux might just be billing per image generation, and that one question took four turns to answer because I kept reasoning instead of looking. The sequence is worth recording exactly, because each step felt like diligence at the time:
- I checked telemetry and found the charge varies (0.017 twice on the same model), so not a flat per-generation fee. Correct.
- I then “discovered” that my own note claiming every flux render returns 1920x1920 was fabricated. I measured ten files in the render directory, saw they were all ~1 MP, and concluded the number had no basis. I also revised the ADR to say the flat calculator was therefore safer than I had claimed.
- Adam said “read openrouter docs”. The docs reversed step 2 entirely.
- Two renders confirmed the docs.
The docs fact I could not have derived: flux megapixel pricing is tiered.
klein-4b charges “The first generated megapixel is charged 0.001”; flux.2-pro 0.015, plus
$0.015/MP for input reference images. And /endpoints returns a single
output_image / megapixel row carrying only the first rate, so the tier is
invisible to the API our enrichment reads.
That single fact explained everything the previous three turns could not.
$0.017 is 0.014 + 2.686 x 0.001 — a ~3.7 MP image. The figure I had just
declared fabricated was right. My “disproof” was that no file in the render
directory matched it; those files were simply not those renders, and I had
treated a missing artifact as evidence against a number.
Then two renders settled it by observation rather than arithmetic. I requested 1920x1080 and got 1920x1920 back, so the delivered size really does exceed and ignore the request. And the first render landed on the most useful case without my choosing it: 1024x768, below a megapixel, charged 0.014 because the first megapixel is a minimum charge; the old linear path would have said $0.011, 21% under. Sub-megapixel is precisely where linear pricing fails in the under direction.
The unplanned finding was the best one. Image output tokens are 256 pixels per token: 1024x768 reported exactly 3072, and 1920x1920 reported 14418, within 0.12%. The response carries the delivered size, which the request never did — so the caveat I had just written into the ADR, that the fallback prices a size these models discard, turned out to be removable. It mattered: billing the requested 1920x1080 would have undercharged that render by 12%.
Cost is tiered, so it never scales with tokens (3072 to 14418 is 4.7x of tokens against 1.21x of cost). Pixels do. Conflating those two is what made the token counts look uninformative for three turns.
I verified the patch by installing pristine litellm into a scratch venv, applying the patch, and calling the calculator with both renders’ real usage: 0.01669 against 0.017 billed. A much better check than the build’s grep marker, and the first time this session I tested the patched code rather than reasoning about it.
The lesson, and it is the whole session in one line: every time I looked instead of inferred, the answer changed. Reading the docs, running the enrichment, measuring files, generating two images. Four looks, four corrections. None of the reasoning in between was worth what any single one of them cost — and the most confident-sounding paragraph I wrote all session, the one “correcting” a fabricated fact, was the most wrong.
The second-order lesson is about how I corrected. Being asked “are you sure” made me re-examine, which is right, but twice I answered it by finding some new evidence rather than the decisive evidence, and a correction made on weak evidence is just a new error with more confidence behind it. The decisive evidence here was always one fetch or one render away.
Top 3 tomorrow
- Watch whether provider-reported cost holds for the per-image family (recraft, seedream). Only the megapixel family was actually exercised, and the per-image family is now the one showing a headline price.
- The image pass costs ~50 requests hourly. Check the logs in a day to confirm the TTL gate is holding and no partial-failure loop developed.
- The 256 px/token ratio rests on two renders, one exact and one 0.12% off. If a third render at a new aspect ratio ever contradicts it, the fallback silently misprices — worth one cheap render to widen the evidence.
- The tier pin is guarded by the first-megapixel rate as a checksum, so a reprice drops it rather than misreporting. Confirm the warning actually appears in the logs if BFL ever moves a price; nothing has exercised that branch outside tests.
Related
2026-09-07 Image Cost Was in the Header and Nowhere Else, 2026-09-05 The LiteLLM Cost Dashboard Was Half the Bill, 2026-09-03 The Histogram Had One Series Per Request, SigNoz Dashboard Design Guide