fix(pricing): keep an unusable rate out of the money math

Prices reach the node from upstream catalogs, an operator's admin edit, a
legacy database row and the BTC/USD feed. json.loads accepts the bare NaN and
Infinity literals and overflows 1e999 to inf, so any of those sources can
deliver a value that is not a price. Three guards let one through:

- The token-rate gate tested truthiness, so NaN and inf reached the token math
  and raised ValueError/OverflowError *after* the response was served. The
  streaming handlers swallow that, so the request went unbilled. A negative
  rate produced a negative charge, which settlement subtracts from the balance.
- An upstream-reported cost component was clamped with max(0.0, ...), which
  passes inf and NaN through. A non-finite component poisoned the proportional
  split in _calculate_from_usd_cost (inf / inf is NaN); the exception was
  absorbed by the broad handler around the USD path, so a request whose total
  was perfectly valid fell through to token estimation and was billed a
  fraction of what the upstream charged. Each spelling is now coerced before
  the fallback chooses between them, so a malformed first field cannot win the
  `or` and hide the usable figure beside it.
- An exchange quote that was zero, negative or non-finite was accepted as the
  node's BTC/USD price, repricing every model on the node.

Each now declines to price rather than billing a nonsensical amount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Ubbink
2026-08-25 15:42:15 +02:00
co-authored by Claude Opus 5
parent 8a0547a8a0
commit dbb9df13f2
3 changed files with 424 additions and 23 deletions
+47 -11
View File
@@ -201,13 +201,14 @@ async def calculate_cost(
cost_details = usage_data.get("cost_details", {})
if not isinstance(cost_details, dict):
cost_details = {}
input_usd = _coerce_usd(
cost_details.get("input_cost")
or cost_details.get("upstream_inference_prompt_cost")
# Coerce each spelling before choosing between them: `inf` and `NaN`
# are truthy, so a malformed first field would otherwise win the
# fallback and the usable figure beside it would never be read.
input_usd = _coerce_usd(cost_details.get("input_cost")) or _coerce_usd(
cost_details.get("upstream_inference_prompt_cost")
)
output_usd = _coerce_usd(
cost_details.get("output_cost")
or cost_details.get("upstream_inference_completions_cost")
output_usd = _coerce_usd(cost_details.get("output_cost")) or _coerce_usd(
cost_details.get("upstream_inference_completions_cost")
)
cache_pricing_rates: tuple[float, float, float, float] | None = None
if cache_read_tokens > 0 or cache_creation_tokens > 0:
@@ -267,9 +268,22 @@ async def calculate_cost(
else:
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
if not (input_rate and output_rate):
# Local import mirrors this module's existing lazy pricing imports.
from .models import is_usable_rate
# An unusable rate is not "no pricing" to Python's truthiness: `NaN` and a
# negative float are both truthy, so they sailed past this gate — the one
# guard meant to catch a rate that cannot be billed on — and reached the
# token math, which raises `ValueError` on `NaN` and `OverflowError` on
# `inf` *after* the response was served (the streaming handlers swallow
# that, so the request goes unbilled), while a negative produced a negative
# charge. Ask whether each rate is usable rather than whether it is truthy.
rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
if not all(is_usable_rate(rate) for rate in rates) or not (
input_rate and output_rate
):
logger.warning(
"No token pricing configured — billing at flat MaxCostData. "
"No usable token pricing — billing at flat MaxCostData. "
"Token counts %s in the upstream response but cannot be "
"priced; the request will appear in dashboards with the "
"raw counts and a fixed max-cost charge.",
@@ -279,6 +293,8 @@ async def calculate_cost(
"model": response_data.get("model", "unknown"),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_rate": input_rate,
"output_rate": output_rate,
},
)
return MaxCostData(
@@ -313,15 +329,35 @@ async def calculate_cost(
def _coerce_usd(value: object) -> float:
"""Coerce a value to USD float, handling various formats safely."""
"""Coerce an upstream-reported USD figure to a usable amount, else ``0.0``.
These values come straight off the upstream response, where ``json.loads``
accepts the bare ``NaN``/``Infinity`` literals and overflows ``1e999`` to
``inf``. A non-finite figure is not a cost, and letting one through poisoned
the proportional split in ``_calculate_from_usd_cost`` (``inf / inf`` is
``NaN``): the resulting exception was absorbed by the broad handler around
the USD path, so a request whose *total* cost was perfectly valid fell
through to token-estimated pricing and was billed a fraction of what the
upstream charged.
``0.0`` means "no usable figure" to every caller, which is the same thing an
absent field means, so the caller's existing ``> 0`` checks handle it.
"""
# Local import mirrors this module's existing lazy pricing imports.
from .models import is_usable_rate
if value is None or isinstance(value, bool):
return 0.0
if not isinstance(value, (int, float, str)):
return 0.0
try:
return max(0.0, float(value))
except (TypeError, ValueError):
# An oversized integer raises OverflowError, not ValueError.
amount = float(value)
except (TypeError, ValueError, OverflowError):
return 0.0
# `is_usable_rate` also rejects negatives, which the previous `max(0.0, …)`
# clamped to zero — same outcome, stated once instead of inline.
return amount if is_usable_rate(amount) else 0.0
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
+60 -12
View File
@@ -12,16 +12,68 @@ BTC_USD_PRICE: float | None = None
SATS_USD_PRICE: float | None = None
def _parse_quote(raw: object, exchange: str) -> float | None:
"""Coerce an exchange quote to a price, or ``None`` if it is not one.
Every quote passes through here because the aggregator takes the ``min()``
of what it collects: an unusable quote does not merely join the sample, it
*wins* it, and the result is the rate every model and every request on the
node is priced at. A zero divides by zero on the USD cost path, ``NaN``
raises out of the integer conversion in settlement, and a negative rate
produces a negative charge that is credited back to the caller.
``is_usable_rate`` is the same predicate the billable-rate guards use, so
"finite and non-negative" has one definition; a quote is stricter still and
must be positive, since a BTC price of zero is a broken feed, not free money.
Imported lazily because ``payment.models`` imports this module.
"""
from .models import is_usable_rate
# A boolean is a shape change, not a price: `float(True)` is a finite,
# positive 1.0 that passes every numeric guard below and then wins the
# `min()`, pricing the node at one dollar per bitcoin.
if isinstance(raw, bool):
logger.warning(
"Non-numeric price quote — ignoring this exchange",
extra={"exchange": exchange, "quote": repr(raw)},
)
return None
try:
price = float(raw) # type: ignore[arg-type]
except (TypeError, ValueError, OverflowError) as e:
logger.warning(
"Unparseable price quote — ignoring this exchange",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": exchange,
"quote": repr(raw),
},
)
return None
if not is_usable_rate(price) or price <= 0:
logger.warning(
"Unusable price quote — ignoring this exchange",
extra={"exchange": exchange, "quote": price},
)
return None
return price
async def _kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Kraken API."""
api = "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"
try:
response = await client.get(api)
price_data = response.json()
price = float(price_data["result"]["XXBTZUSD"]["c"][0])
return price
except (httpx.RequestError, KeyError) as e:
return _parse_quote(price_data["result"]["XXBTZUSD"]["c"][0], "kraken")
except (httpx.RequestError, KeyError, IndexError, TypeError, ValueError) as e:
# A payload whose *shape* changed raises IndexError/TypeError, and a
# non-JSON body raises ValueError; unhandled, one exchange's bad day
# aborted the whole aggregation instead of dropping a single quote.
logger.warning(
"Kraken API error",
extra={
@@ -39,10 +91,8 @@ async def _coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
try:
response = await client.get(api)
price_data = response.json()
price = float(price_data["data"]["amount"])
return price
except (httpx.RequestError, KeyError) as e:
return _parse_quote(price_data["data"]["amount"], "coinbase")
except (httpx.RequestError, KeyError, IndexError, TypeError, ValueError) as e:
logger.warning(
"Coinbase API error",
extra={
@@ -60,10 +110,8 @@ async def _binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
try:
response = await client.get(api)
price_data = response.json()
price = float(price_data["price"])
return price
except (httpx.RequestError, KeyError) as e:
return _parse_quote(price_data["price"], "binance")
except (httpx.RequestError, KeyError, IndexError, TypeError, ValueError) as e:
logger.warning(
"Binance API error",
extra={
+317
View File
@@ -0,0 +1,317 @@
"""Tests that an unusable rate never reaches the money math.
A billable rate is usable only when it is finite and non-negative. Prices reach
the node from upstream catalogs, an operator's admin edit, a legacy database row
and the BTC/USD feed, and each of those can deliver ``NaN``, ``±inf`` or a
negative — ``json.loads`` accepts the bare ``NaN``/``Infinity`` literals and
overflows ``1e999`` to ``inf``.
These tests cover the guards between such a value and a charge: the token-rate
gate that decides a model cannot be priced, the upstream-reported USD cost, the
exchange-rate feed, and the stored-row read path. They assert the node declines
to price the request rather than billing a nonsensical amount or raising after
the response has already been served.
"""
from __future__ import annotations
import math
from collections.abc import Iterator
from typing import Any
from unittest.mock import patch
import pytest
from routstr.payment.cost_calculation import (
CostData,
MaxCostData,
calculate_cost,
)
from routstr.payment.models import (
Architecture,
Model,
Pricing,
)
@pytest.fixture(autouse=True)
def patch_sats_usd_price() -> Iterator[None]:
"""Pin the exchange rate; these tests are about the rates, not the feed."""
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
yield
def _architecture() -> Architecture:
return Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="unknown",
instruct_type=None,
)
def _model(sats_pricing: Pricing) -> Model:
return Model(
id="m",
name="m",
created=0,
description="d",
context_length=8192,
architecture=_architecture(),
pricing=Pricing(prompt=1e-06, completion=2e-06),
sats_pricing=sats_pricing,
)
def _usage_response() -> dict[str, Any]:
return {"model": "m", "usage": {"prompt_tokens": 1000, "completion_tokens": 500}}
@pytest.mark.parametrize(
"bad_rate",
[float("nan"), float("inf"), -5.0],
ids=["nan", "inf", "negative"],
)
@pytest.mark.asyncio
async def test_unusable_token_rate_falls_back_to_max_cost(bad_rate: float) -> None:
"""An unusable configured rate must not be billed on.
The "no token pricing configured" gate is a truthiness test, and ``NaN`` and
negative floats are both truthy, so an unusable rate passes the guard that
exists to catch it. It then reaches the integer conversion in the token math,
which raises ``ValueError`` for ``NaN`` and ``OverflowError`` for ``inf`` —
after the upstream response has already been served, where the streaming
handlers swallow it and the request goes unbilled.
"""
model = _model(Pricing(prompt=bad_rate, completion=1.0))
cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model)
assert isinstance(cost, MaxCostData)
assert cost.total_msats == 1234
@pytest.mark.parametrize(
"junk", [float("inf"), float("nan"), "Infinity"], ids=["inf", "nan", "inf-string"]
)
@pytest.mark.asyncio
async def test_junk_cost_component_still_bills_the_reported_total(junk: Any) -> None:
"""A malformed component must not discard the upstream's real total cost.
``cost_details`` only splits the total across input and output; the total is
the authoritative billed amount. A non-finite component poisons the
proportional allocation (``inf / inf`` is ``NaN``), which raised out of the
USD path and was swallowed by the broad handler around it — so the request
silently fell through to token-estimated pricing and was billed at a small
fraction of what the upstream actually charged.
"""
model = _model(Pricing(prompt=1e-06, completion=2e-06))
response = {
"model": "m",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 500,
"cost": 0.01,
"cost_details": {"input_cost": junk, "output_cost": 0.004},
},
}
cost = await calculate_cost(response, max_cost=9999, model_obj=model)
assert isinstance(cost, CostData)
# $0.01 at 5.0e-5 USD/sat = 200 sats = 200_000 msats.
assert cost.total_msats == 200000
assert cost.total_usd == pytest.approx(0.01)
@pytest.mark.asyncio
async def test_junk_cost_component_falls_back_to_its_alternate_field() -> None:
"""A malformed component must not shadow the field that would have replaced it.
Each side of the split has two spellings and the second is a fallback for a
missing first. ``inf`` and ``NaN`` are both truthy, so a malformed
``input_cost`` won that choice before anything checked whether it was a
number, and the usable figure beside it was never read — the input side was
then billed at nothing and the whole total landed on output.
"""
model = _model(Pricing(prompt=1e-06, completion=2e-06))
response = {
"model": "m",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 500,
"cost": 0.01,
"cost_details": {
"input_cost": float("inf"),
"upstream_inference_prompt_cost": 0.006,
"output_cost": 0.004,
},
},
}
cost = await calculate_cost(response, max_cost=9999, model_obj=model)
assert isinstance(cost, CostData)
# $0.01 at 5.0e-5 USD/sat = 200_000 msats, split 0.006 : 0.004.
assert cost.total_msats == 200000
assert (cost.input_msats, cost.output_msats) == (120000, 80000)
@pytest.mark.asyncio
async def test_non_finite_reported_cost_is_not_a_cost() -> None:
"""An upstream-reported ``Infinity`` cost is junk, not an infinite charge.
``json.loads`` accepts the bare ``Infinity`` literal, so a compromised or
buggy upstream can put one in ``usage.cost``. It must not be treated as a
positive USD cost at all — the request falls through to the node's own token
pricing instead.
"""
model = _model(Pricing(prompt=1e-06, completion=2e-06))
response = {
"model": "m",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 500,
"cost": float("inf"),
},
}
cost = await calculate_cost(response, max_cost=9999, model_obj=model)
assert isinstance(cost, CostData)
assert math.isfinite(cost.total_usd)
assert cost.total_msats == 2
class _ExchangeResponse:
def __init__(self, payload: dict[str, Any]) -> None:
self._payload = payload
def json(self) -> dict[str, Any]:
return self._payload
class _ExchangeClient:
"""Answers each exchange endpoint with a caller-supplied quote."""
def __init__(self, quotes: dict[str, Any]) -> None:
self._quotes = quotes
async def get(self, url: str) -> _ExchangeResponse:
if "kraken" in url:
return _ExchangeResponse(
{"result": {"XXBTZUSD": {"c": [self._quotes["kraken"]]}}}
)
if "coinbase" in url:
return _ExchangeResponse({"data": {"amount": self._quotes["coinbase"]}})
return _ExchangeResponse({"price": self._quotes["binance"]})
class _AsyncCtx:
def __init__(self, client: _ExchangeClient) -> None:
self._client = client
async def __aenter__(self) -> _ExchangeClient:
return self._client
async def __aexit__(self, *exc: object) -> bool:
return False
@pytest.fixture
def refresh_price_with() -> Iterator[Any]:
"""Refresh the node's BTC/USD price from caller-supplied exchange quotes.
Restores the module's cached price afterwards so one test cannot set the
rate another one bills at.
"""
import routstr.payment.price as price_module
previous = (price_module.BTC_USD_PRICE, price_module.SATS_USD_PRICE)
async def _run(quotes: dict[str, Any], last_good: float | None = None) -> None:
price_module.BTC_USD_PRICE = last_good
price_module.SATS_USD_PRICE = (
None if last_good is None else last_good / 100_000_000
)
with patch.object(
price_module.httpx,
"AsyncClient",
lambda *a, **k: _AsyncCtx(_ExchangeClient(quotes)),
):
await price_module._update_prices()
yield _run
price_module.BTC_USD_PRICE, price_module.SATS_USD_PRICE = previous
@pytest.mark.parametrize(
"bad_quote",
["0", "0.00000000", "-1", "NaN", "Infinity", "N/A"],
ids=["zero", "zero-padded", "negative", "nan", "infinity", "non-numeric"],
)
@pytest.mark.asyncio
async def test_unusable_exchange_quote_does_not_set_the_node_price(
bad_quote: str, refresh_price_with: Any
) -> None:
"""One exchange returning junk must not set the price the node bills at.
The feed takes the ``min()`` of the quotes it collects, so an unusable quote
does not merely join the sample — it *wins*, and poisons the rate every model
and every request is priced at until the next refresh. Zero then divides by
zero on the USD path, ``NaN`` raises out of the integer conversion, and a
negative rate produces a negative charge that settlement credits back to the
caller.
The two healthy quotes must still price the node.
"""
from routstr.payment.price import btc_usd_price
await refresh_price_with(
{"kraken": bad_quote, "coinbase": "100000.0", "binance": "100000.0"}
)
assert btc_usd_price() == pytest.approx(100000.0)
@pytest.mark.asyncio
async def test_boolean_exchange_quote_does_not_set_the_node_price(
refresh_price_with: Any,
) -> None:
"""A boolean in the price field is a shape change, not a $1 bitcoin.
``float(True)`` is ``1.0``, which is finite and positive, so a payload whose
price field turned into a boolean passes every numeric guard — and then
*wins* the ``min()``, pricing the whole node at one dollar per bitcoin. The
node's other coercions all reject ``bool`` before the numeric check for this
reason; this one is the exception.
"""
from routstr.payment.price import btc_usd_price
await refresh_price_with(
{"kraken": True, "coinbase": "100000.0", "binance": "100000.0"}
)
assert btc_usd_price() == pytest.approx(100000.0)
@pytest.mark.asyncio
async def test_all_quotes_unusable_keeps_the_last_good_price(
refresh_price_with: Any,
) -> None:
"""When every quote is junk the node keeps the last price it trusted.
Adopting ``0`` or ``NaN`` because it was the only thing on offer would take
out billing for every model at once; skipping the update degrades to a stale
rate, which is the safe direction and what an unreachable exchange already
does.
"""
from routstr.payment.price import btc_usd_price
await refresh_price_with(
{"kraken": "0", "coinbase": "NaN", "binance": "-3"}, last_good=90000.0
)
assert btc_usd_price() == pytest.approx(90000.0)