Skip to content

openrouter: price models from the live rate card, not only the bundled map - #10

Open
jwbron wants to merge 1 commit into
mainfrom
openrouter-live-pricing
Open

openrouter: price models from the live rate card, not only the bundled map#10
jwbron wants to merge 1 commit into
mainfrom
openrouter-live-pricing

Conversation

@jwbron

@jwbron jwbron commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Sequel to #8, same file, same fetch.

#8 fixed half of a two-headed problem: model_prices_and_context_window.json does not carry current OpenRouter slugs, so supports_reasoning answered False for a current model and its reasoning knob was dropped in silence. The same absence has a second consequence nobody had chased — _get_model_info_helper raises "This model isn't mapped yet" for those slugs, so response_cost is never computed and every cost consumer downstream reports nothing.

Found in jwbron/egg#3691, where it meant 1252 of 1252 sampled calls logged a null cost and the project had no dollar figure at all for its LLM spend.

What this does

Reads the pricing block out of the same GET /api/v1/models response capabilities.py already caches, and hands it to the model-info lookup at the raise site — after every map lookup has already failed. A slug the bundled map does carry keeps the bundled answer: the live card can add a model but never reprice one.

Two deliberate limits, both about not trading a known unknown for a confident wrong number:

  • Cost fields only. supports_reasoning: true arriving through this door would make get_supported_openai_params admit thinking — a different wire shape from OpenRouter's reasoning, forwarded verbatim to a provider that does not accept it. Parameter admission stays exactly where openrouter: read supported params from OpenRouter instead of the bundled model-cost map #8 put it. Context lengths are omitted for the same reason: this entry changes what a call costs and nothing else.
  • Tiered rate cards are declined, not approximated. OpenRouter expresses a long-context surcharge as pricing.overrides keyed by an arbitrary min_prompt_tokens — qwen3-max publishes 32000 and 128000. This map has slots for three fixed thresholds and drops the rest, so a faithful translation does not exist in general, and registering the base tier would under-report by 2-2.5x on exactly the long prompts such a model gets chosen for. Silently, under a field an operator would use to compare models. Those stay unpriced and log the reason once, naming the tiers. 49 of 367 models on the roster are tiered; the other 87% get a real number.

Drift repair

The twin of this file in jwbron/egg (config/litellm/openrouter_capabilities.py) has been hardened since #8 landed. #8's own commit message flagged the drift risk; this folds the divergence back:

  • Fetch failures were logged at debug. verbose_logger defaults to INFO, so a permanently unreachable endpoint reverted behaviour to the bundled map in total silence — the same silent-fallback problem Log when drop_params discards caller-specified params #7's drop_params warning exists to remove, one file over. First failure now warns, the rest are debug, re-armed by a successful fetch so a startup blip cannot mute a real outage hours later.
  • Bad env values were swallowed. TTL=5s or TIMEOUT=0 took the default with no signal. Worse, _env_flag read every unrecognized value as enable — so FETCH=disabled inverted the operator's instruction rather than falling back to the default. Both now warn once per distinct (name, value).
  • _get_cache held the lock unconditionally, so a request arriving during a refresh queued behind a network call. httpx timeouts are per-phase, not total, so a pathological connection puts that latency on a live request once per TTL. A thread that finds the lock held now serves the stale cache; only the very first fetch blocks.
  • All three warn-once latches now record the dedup key only after the emit did not raise. _log swallows its own failures, so recording first meant a logger not yet in place on the first call suppressed the warning permanently.
  • TTL=0 is accepted as the natural spelling of "never cache", and a 200 whose data list parses to nothing is reported rather than being indistinguishable from an ordinary empty answer.

Tests

live_payload now stubs the HTTP transport instead of _fetch, so the parsing — where the pricing translation lives — is under test rather than mocked past. New coverage: the translation itself (including the input_cache_writecache_creation_input_token_cost mapping, which if swapped prices cache writes at the read rate), the tiered decline, provider scoping, both disable switches, unusable rate cards, and the get_model_info / cost_per_token path end to end against hand-computed arithmetic.

TestDropParamsVisibility in tests/test_litellm/test_utils.py gains the same fetch-disabling fixture the openrouter conftest.py already uses. Those tests were reaching the live model list and doing real HTTP, with an outcome dependent on what OpenRouter published that morning; a tiered model's pricing warning is what made it visible.

Verification

tests/test_litellm/llms/openrouter/ + test_utils.py + litellm_core_utils/: 1677 passed, 8 failed — the 8 are vertex/gemini and health-check failures that reproduce identically on main at 86a53b6. No regressions; the one test my change did break is the network-dependent one described above, fixed here.

ruff check and ruff format clean on the files this touches.

Note for whoever syncs this fork next

This does not need the cost half of the streamed-usage fix — upstream landed that in v1.94.0 (8a49423, port of BerriAI#16162) and this fork already carries it by merge. What upstream still does not carry is cost_details, so the BYOK path (cost: 0 with the real number under cost_details.upstream_inference_cost) records the zero and stops. That is a separate, small PR and probably belongs to BerriAI rather than here.

…d map

PR #8 fixed half of a two-headed problem: `model_prices_and_context_window.json`
does not carry current OpenRouter slugs, so `supports_reasoning` answered False
for a current model and its reasoning knob was dropped in silence. The same
absence has a second consequence nobody had chased. `_get_model_info_helper`
raises "This model isn't mapped yet" for those slugs, so `response_cost` is
never computed and every cost consumer downstream reports nothing. For a proxy
routing current slugs that is not an edge case; it is every call.

OpenRouter publishes the rate card in the same `GET /api/v1/models` response
`capabilities.py` already fetches, so this reads it off the existing cache and
hands it to the model-info lookup at the raise site, after every map lookup has
already failed. A slug the bundled map DOES carry keeps the bundled answer: the
live card can add a model but never reprice one.

Two deliberate limits, both about not trading a known unknown for a confident
wrong number:

  * Cost fields only. `supports_reasoning: true` arriving through this door
    would make `get_supported_openai_params` admit `thinking`, a different wire
    shape from OpenRouter's `reasoning` that would be forwarded verbatim to a
    provider that does not accept it. Parameter admission stays where PR #8 put
    it. Context lengths are omitted for the same reason: the entry changes what
    a call costs, nothing else.

  * A rate card tiered by prompt length is declined outright. OpenRouter
    expresses a long-context surcharge as `pricing.overrides` keyed by an
    arbitrary `min_prompt_tokens` — qwen3-max publishes 32000 and 128000. This
    map has slots for three fixed thresholds and drops the rest, so a faithful
    translation does not exist in general, and registering the base tier would
    under-report by 2-2.5x on exactly the long prompts such a model is chosen
    for. Silently, under a field an operator would use to compare models. Those
    stay unpriced and log the reason once, naming the tiers. 49 of 367 models on
    the roster are tiered; the rest get a real number.

Also folds in the hardening the twin of this file has accumulated in jwbron/egg
since PR #8 (config/litellm/openrouter_capabilities.py). The two are meant to
stay line-for-line comparable and had drifted:

  * Fetch failures were logged at `debug`. verbose_logger defaults to INFO, so
    a permanently unreachable endpoint reverted behaviour to the bundled map in
    total silence — the same silent-fallback problem the drop_params warning in
    PR #7 exists to remove. Now the first failure warns and the rest are debug,
    re-armed by a successful fetch so a startup blip cannot mute a real outage.

  * Bad values for the env knobs were swallowed. `TTL=5s` or `TIMEOUT=0` took
    the default with no signal, and `_env_flag` read every unrecognized value
    as enable — so `FETCH=disabled` inverted the operator's instruction rather
    than falling back. Both now warn once per distinct (name, value).

  * `_get_cache` held the lock unconditionally, so a request arriving during a
    refresh queued behind a network call. httpx timeouts are per-phase, not
    total, so that latency lands on a live request once per TTL. A thread that
    finds the lock held now serves the stale cache; only the very first fetch
    blocks.

  * All three warn-once latches record the dedup key only after the emit did
    not raise. `_log` swallows its own failures, so recording first meant a
    logger that was not yet in place on the first call suppressed the warning
    permanently.

  * `TTL=0` is accepted as the natural spelling of "never cache", and a 200
    whose `data` list parses to nothing is now reported rather than being
    indistinguishable from an ordinary empty answer.

Tests: the `live_payload` fixture now stubs the HTTP transport rather than
`_fetch`, so the parsing — where the pricing translation lives — is under test
instead of mocked past. Adds coverage for the translation, the tiered decline,
provider scoping, the disable switches, unusable rate cards, and the
`get_model_info` / `cost_per_token` path end to end.

`TestDropParamsVisibility` in tests/test_litellm/test_utils.py gains the same
fetch-disabling fixture the openrouter conftest already uses. Those tests were
reaching the live model list and thus doing real HTTP with an outcome dependent
on what OpenRouter published that morning; a tiered model's pricing warning is
what made it visible.

Operator knob: LITELLM_OPENROUTER_PRICING=0 disables the pricing half alone.
LITELLM_OPENROUTER_CAPABILITY_FETCH=0 still governs both, since one fetch
serves both.
# line that was never emitted would demote every later outage to debug
# without anyone having seen the first one.
if _log(level, "openrouter capabilities: " + message, *args):
_WARNED_FETCH_FAILURE = True
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants