2026-09-07 Image Cost Was in the Header and Nowhere Else
What I set out to do
Give the new litellm-mcp-server image tool a cost figure. It had one, computed
client-side from /model/info rates, and I spent most of the session making
that number correct instead of asking what the number was for.
What I actually did
Adam stated the actual goal in five points, the first being that costs exist to
be ingested into the collector and viewed there later. One query settled it:
sum(gen_ai.cost.total_cost) grouped by http.route returned chat completions
and nothing else. The image routes were billing real money and contributing
nothing. I had verified the response header and the tool response and never
once checked SigNoz.
Root cause: LiteLLM has two independent cost channels. completion_cost returns
early on the image path (cost_calculator.py:1344) before
_store_cost_breakdown_in_logging_obj, and the OTel exporter emits
gen_ai.cost.* only from standard_logging_payload["cost_breakdown"]. So a
request can carry a correct response_cost and a correct
x-litellm-response-cost header while exporting no cost at all. My earlier
patch fixed the header. It never touched the telemetry.
Then a detour into the OTel GenAI conventions, which turned out to matter. They
have moved to their own repository (semantic-conventions-genai); the pages
under opentelemetry.io/docs/specs/semconv/gen-ai are redirect stubs, so the
rendered registry I would normally cite is stale. There is no image-generation
operation and no proposal for one, and the reason is structural rather than an
oversight: an operation name forms the span name, is sampling_relevant, is a
required metric dimension, and selects which of the eleven span-type attribute
contracts applies. An image call shares the inference contract exactly, so
modality rides the orthogonal gen_ai.output.type — one generateContent call
can return text and an image together, which a single operation name could not
describe. Cost is not in the conventions either; PR #443
(gen_ai.usage.cost.{amount,currency,source}) is open and names LiteLLM’s shape
as one of three incompatible ones already shipping.
Wrote ADR 0029 (docs/decisions/0029-genai-semconv-image-observability.md)
and implemented it:
- Turned on
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental, an opt-in this deployment had never set. Free: CLIENT span kind,{operation} {model}span names,gen_ai.provider.name, thegen_ai.request.*sampling attributes,choice.count, cache tokens. - Cost breakdown in a plugin, not a patch. Verified in the installed source
that every callback’s
async_logging_hookreceivesmodel_call_details— which already holds the builtstandard_logging_object— before any callback’sasync_log_success_event. So aCustomLoggercan populate the breakdown ahead of the exporter with no source change and no dependence on callback ordering.litellm_image_cost_breakdown.py, 28 tests. - One narrow patch for what no hook can reach:
gen_ai.output.type, thegenerate_contentoperation mapping, and the images-API usage shape. The exporter builds its attribute set internally and ends the span in the same call, so this genuinely cannot be a plugin. - Deleted the client-side pricing from the MCP server, with a test asserting
the render path never requests
/model/info— a second pricing source growing back is the failure being guarded against. - Migrated 18 dashboard queries off
name = 'litellm_request'.
Card library: one answer for who {{char}} is
Second thread, on openrouter-interceptor. Yesterday’s --nickname migration
put the character’s real name in data.nickname and left the JanitorAI listing
title in name, across 238 cards. Adam asked for spot checks, which is how the
defect surfaced.
262 of 274 migrated cards render byte-identical before and after, and the 12
that differ are all the listing title leaving somewhere it never belonged. But
all six cast nicknames were wrong: {{char}} stands for one person, and these
held a roster. <Rory, Hudson, Milo> headed one woman’s sheet;
Marie and Josh is {{user}}'s step-mother was one woman’s scenario.
Root cause was the recurring one in this repo. The roster grammar was spelt
three times: names.ts split on [,|&/] and “and”, asCharacterName split
on | alone by accident of cleanExtractedName, and name-from-capture kept a
third copy without the |. So Cassidy | Tony | Sterling was Cassidy and
Rory, Hudson, Milo stayed a cast, and which a card got depended on how its
creator had punctuated the tag. eachName is the one answer now, and
asCharacterName is the first person it finds.
Second fix, on Adam’s follow-up: asClaimedName outranks every other source so
a card can keep a name an extractor would refuse to guess (“the card named Chad
is named Chad”), and it was granting that claim on the cleaned candidate.
personaName strips punctuation first, so Mom's Wedding? arrived as two
ordinary words, passed, and beat the chat_name that names the person.
isScenarioTitle now knows nobody is named a question, and the guard covers the
claim itself rather than only the fallback branch it never reached.
Applied both to the live library: 212 cards, every one of them gaining {{char}}
macros, 197 of the 212 rendering byte-identical afterwards and all 15 that
differ being a cast or a title leaving somewhere one person’s name belongs.
Third fix, for the 14 cards left rendering a title with no capture to read: nine carried the name in the title itself with a descriptor after it, so the title proposes and the card’s own prose decides. A genre tag is read past, an excluded word ends the name rather than being dropped from it, and the hyphen is offered both ways because it separates a descriptor in “Nami-Wife Porn Star” and joins a name in “Silk-E”. Corroboration is on the exact spelling, which is what refuses “Hotwife Fucks the Porn Shop”: its only agreement is “extremely sexual and adventurous hotwife”, the common noun the title capitalised. 235 title renders down to 58, and the five left name nobody anywhere in the card.
That last claim was wrong, and Adam asking me to look through them “more
seriously” is what found it. Three of the five do name their character; I had
checked for a Name: field and stopped. After 3 Years at War opens its
description with Dione: and writes Dione 199 times, and the decisive evidence
was in the example dialogues: ten speakers, and hers are the only lines
macroised. Its persona tag is Narrator, which is why nothing found her. fake doctor that's coming over opens [Name: Jenny, Role: Your Wife], and the title
names Dr. Boner’s role while every macro is Jenny’s line. The three brothers
introduce themselves as The Spencer Brothers. Fixed by hand, one script,
each on its own evidence.
Then three more sweeps, each finding something the validation report does not
check. Seven cards rendered a doubled surname ({{char}} Wong under a card
named Amy Wong, “Sora Jae Jae”, “Mia Mia Harper” eight times over) from a
conversion run against a shorter spelling than the field ended up with; the
collapse went into the chain, keyed on the name’s own first and last word.
Eleven cards shouted a name their prose writes in title case, which both
mis-rendered and blocked macroisation outright, since the conversion matches
case-sensitively: Akari 53 times, Liora 52, Mikaru 38. And --nickname mode
had a hole I put there: the macroiser read the name field, which in that mode
is deliberately the listing title, so a card whose nickname was its only source
was never macroised at all.
All eleven of those render byte-identical before and after, which is the whole point of a macroisation. Title renders across the library went 235 → 58 → 1, doubling 7 → 0.
What was striking
Two silent defects, both found by looking at the span rather than the code.
The first revision set the image attributes before the existing usage block,
which then overwrote the real token totals with the zeros a chat-shaped read
produces — while leaving the modality breakdown beside them correct, which is
the most confusing possible outcome. And gen_ai.provider.name came back as the
empty string, because litellm_params carries "" rather than nothing, so the
"Unknown" default never fires. Present-and-empty is worse than absent: it
groups.
The replacement filter is better than the one it replaces, and by accident.
gen_ai.operation.name EXISTS was chosen because the new span name is per-model
and unusable as a filter. It turns out the attribute was set in the legacy shape
too, so the panels keep their history across the cutover instead of restarting
at zero. Confirmed by rendering the dashboard rather than reading the queries —
2026-09-01 The Rename Was the Visible Half is the reason I no longer trust a
query that merely looks right.
The before/after in one query, 24h, by operation: acompletion 0.134 on
the new one.
I had the wrong file open for a while. LiteLLM 1.98.0 ships two OTel
integrations — the legacy integrations/opentelemetry.py and a newer
integrations/otel/ package gated on LITELLM_OTEL_V2. Our config references a
symbol from the new one, which sent me reading it, but the flag is unset so the
legacy path is live. Worth knowing that v2 defines gen_ai.output.type and
never uses it, so migrating would not have avoided the patch.
Instrumenting the MCP server was nearly free, and I had talked myself out of
it. I closed the session recommending against it — new OTel deps through
uv2nix, exporter config, a second collector service, for correlation a
one-call-per-invocation server barely needs. Adam pushed back that it was
“probably fairly free with fastmcp”, and it was: FastMCP has native
OpenTelemetry instrumentation, depends only on opentelemetry-api, and ships
telemetry_mode="native" by default. It was already opening mcp.server spans
carrying mcp.method.name, gen_ai.tool.name, mcp.session.id and
mcp.protocol.version, and already propagating context. They were non-recording
for want of an SDK. Two dependencies, a TracerProvider, and one httpx event
hook calling propagate.inject on the outbound proxy call, and one
generate_image call now produces one trace:
tools/call generate_image litellm-mcp SERVER 9.81s (root)
└── Received Proxy Server Request litellm-proxy SERVER 9.64s
└── generate_content google/… litellm-proxy CLIENT 9.64s
The span carrying the cost is a descendant of the tool call that caused it. My cost/benefit was right in the abstract and wrong on the facts — I estimated the cost of building it without checking what the framework already did.
Emitting a signal is not the same as being able to see it. I migrated the
existing 18 panel queries so nothing broke, reported that as done, and left the
new attributes with no panel reading them. Adam asked whether I had added image
and MCP widgets; I had not. Two new sections now: Image Generation (renders,
spend, cost per render, p95 render time, cost over time by model, and a
per-model table pairing $/render with gen_ai.usage.image.output_tokens) and
MCP Server (tool calls, errors, p95 duration, tools used, calls by tool,
duration by tool). Cost per render is the one that earns its place — image
output tokens bill at ~20x the text rate, so a model swap moves it far more than
it moves cost per chat call.
Rendering them caught what reading the JSON would not: six columns overflowed a
six-wide table, and the By Image Model row read lower than the same model’s row
in By Model because renders predating today carry no gen_ai.output.type. Fixed
the first, documented the second in the panel description.
Dashboard sections do not nest, and finding that out needed a control.
spec.layouts is a flat list whose union has exactly one member — the server
enumerates it, unknown layout kind ""; allowed values: 'Grid' — and a layout
item’s content must resolve to a panel: "#/spec/layouts/1" must reference a panel as "#/spec/panels/<key>". My first three probes all failed, and every one
of them would have read as “nesting rejected” if I had stopped there. They were
failing on payload shape instead: the file we maintain is in the terraform
provider form ({"grid": {...}}, ref, snake_case) while the API wants
{"kind": "Grid"}, $ref, camelCase. The flat control failed identically each
time, which is the only reason I did not draw the wrong conclusion. Fastest way
to a valid probe turned out to be copying a panel verbatim out of the live
dashboard’s own GET payload.
So MCP got flattened into the foot of Agentic & Tool Use rather than nested
inside it, and Latency moved up into Traffic. Flattening cost that section its
unambiguous names — “Tool Calls” meant both tools the models called through the
proxy and tools clients called on our MCP server — so the latter carry an MCP
prefix that is load-bearing rather than decorative.
Claude Desktop had been silently skipping HTTP MCP servers all along. I
inferred it from missing logs — every stdio server has an
mcp-server-<name>.log, neither HTTP one ever did — and Adam produced the
actual toast: “The following entries in claude_desktop_config.json are not
valid MCP server configurations and were skipped: jupyter, litellm.” So
jupyter had been dead in Desktop for as long as it had been configured, and
nobody had reason to notice.
Wrote mcp-stdio-bridge, a transport adapter speaking stdio to the client and
streamable HTTP to the server. Generic by design — it takes a URL, so the one
binary fixes both entries. Not a second server: one thin relay per Desktop
launch against the one long-lived HTTP instance, so the HTTP-only decision
survives intact.
Two bugs found by testing rather than reasoning, both in teardown:
tg.cancel_scope.cancel()on stdin EOF leaked the process — the scope cancelled but teardown never completed, against a live endpoint as much as a dead one, so every Desktop shutdown would have left a bridge running forever with nothing to report it.await sink.aclose()instead lets the HTTP writer finish, which ends the response stream, which ends the other pump; the relay unwinds itself.- Treating a stream
Exceptionas a skippable bad frame hung the client. A connection failure arrives that way, and logging-and-continuing leaves the client blocked on a response that can never come.
The session test passed while the leak was live, which is the lesson: it closed cleanly enough not to notice. The regression test that pins it asserts the process exits, not that the session works.
Also fixed a merge-patch bug found on the way: transformMcpServer filters
nulls out of what it emits and RFC 7396 preserves absent keys, so nothing the
desktop module wrote could ever delete a field. litellm was deployed as a
hybrid — stale command, args and an env pointing at the retired
~/Pictures/generated, sitting beside the correct type and url, with a
store path garbage collection was free to remove.
I built a preview for an image that was already fetchable. Adam asked why the render came back downscaled and why the path was a local one. I measured the preview sizes and started proposing a bigger cap — 1024px, ~9k tokens — before he cut in: “there should be no preview. that was the point of the url.” Which is right, and I had missed the point of my own design. The render goes to Drive precisely so the caller can fetch it; re-sending a shrunken copy inline spends thousands of tokens on something already available, at a resolution too small to judge. The measurements were real and the conclusion I was drawing from them was wrong.
The path complaint had a subtler answer: the returned path is the Drive path, the local File Provider mount. But that only means something on this Mac, which is exactly the thing Drive was supposed to fix.
Turns out the URL is obtainable with no OAuth at all. Drive for Desktop keeps a
SQLite catalogue at
~/Library/Application Support/Google/DriveFS/<account>/metadata_sqlite_db, and
items.id is the same id the Drive API returns — checked against both for the
same file. Three traps, each costing a cycle:
- Read it in place, read-only. New rows live in the write-ahead log, so a copy of the main database alone does not have them. My first probe reported “no id after 60s” for a file that had one after 1.1s.
local-NNNNNis a placeholder, not a Drive id. It appears ~1s after the write; the real id replaces it at ~7s. A URL built from the placeholder is confidently broken.with sqlite3.connect(...)does not close the connection — sqlite3’s context manager only ends the transaction. That leaked a handle per poll, and surfaced as ResourceWarnings failing an unrelated test in the same run.
The last one is the good kind of test failure: it pointed at a production leak while looking like test pollution.
The naive fix was wrong in exactly three of 186 cards, and only the dry run
said so. Splitting a roster at the first separator also cut titles in half:
Your Extra Sweet, Not Suspicious Wife became “Your Extra Sweet”, and
Surprise, you're dating the school slut! became “Surprise”, both outranking
the chat_name that named the person. So a member has to name somebody, and a
card claims what its field says rather than a piece of it. I would not have
found any of that from the tests; I found it by counting what the change
proposed to write and asking which proposals gained no macros. That number went
206 → 212 and the no-gain count went 1 → 0, which is the whole verification.
The same duplication keeps coming back in this repo, and it is what Adam has
been naming all week. Yesterday it was five name resolvers folded into one.
Today the roster split turned out to have three spellings, one of them in a
script written after the consolidation. Writing the convention into AGENT.md
did not stop a fresh copy appearing; putting the answer somewhere a caller has
to go through is the only thing that has.
Top 3 tomorrow
- Watch whether
gen_ai.provider.nameis empty on any other non-chat route — the fallback is general but only image was verified. - Decide whether the image spend deserves its own dashboard row now that
gen_ai.output.type = imagemakes it a one-line filter. - Track semconv PR #443; if it lands,
gen_ai.cost.*becomesgen_ai.usage.cost.amountand the panels move again.
Related
2026-09-01 The Rename Was the Visible Half, 2026-09-05 The LiteLLM Cost Dashboard Was Half the Bill, 2026-09-03 The Histogram Had One Series Per Request, 2026-08-20 LiteLLM Patch Stack Rebase onto 1.97.0, SigNoz Dashboard Design Guide, 2026-09-04 OpenRouter Interceptor Review Findings Actioned, 2026-08-28 OpenRouter Interceptor Analyses a Character It Sees Twice