Merge branch 'main' into fix/cashu-mint-interactions

This commit is contained in:
9qeklajc
2026-08-26 22:55:43 +02:00
committed by GitHub
15 changed files with 1899 additions and 78 deletions
+39 -4
View File
@@ -135,9 +135,21 @@ def create_model_mappings(
Returns:
Tuple of (model_instances, provider_map, unique_models)
"""
from .payment.models import _row_to_model
from .payment.models import _row_to_model, has_usable_pricing
from .upstream.helpers import resolve_model_alias
def _unusable_price(model: "Model") -> bool:
"""A candidate may only route on rates a request can be billed against.
Mirrors the served-catalog backstop in ``list_models``: a negative or
non-finite rate is not a price, and the cost calculation cannot bill on
one, so every request on the model would be charged the full maximum
reservation instead. Applies to provider-discovered models as well as
persisted overrides — no override row need exist for a malformed price
to be built into the candidate map.
"""
return not has_usable_pricing(model.pricing)
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
unique_model_keys: dict[str, str] = {}
@@ -225,12 +237,33 @@ def create_model_mappings(
# Apply overrides only for this provider's model row.
if model_key is not None and model_key in overrides_by_key:
override_row, provider_fee = overrides_by_key[model_key]
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
try:
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
except Exception as exc:
# Stored pricing is JSON from whatever wrote the row, so
# converting it can raise. Doing that inside this loop let
# one such row unwind the whole map build: at boot the node
# came up routing nothing, and on a later refresh the map it
# already had went permanently stale. The sibling loop over
# override-only rows already skips and logs such a row.
logger.warning(
"Skipping invalid model override while building model mappings",
extra={
"model_id": model.id,
"upstream_provider_id": upstream_db_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
else:
model_to_use = model
if _unusable_price(model_to_use):
continue
forwarded_model_id = get_effective_forwarded_model_id(model_to_use)
# Get all aliases for this model
@@ -297,6 +330,8 @@ def create_model_mappings(
continue
if not model_to_use.enabled:
continue
if _unusable_price(model_to_use):
continue
forwarded_model_id = get_effective_forwarded_model_id(model_to_use)
+54 -7
View File
@@ -5,12 +5,17 @@ from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, RootModel
from pydantic import BaseModel, RootModel, field_validator
from pydantic.v1 import ValidationError as PydanticValidationError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..payment.models import _row_to_model, list_models
from ..payment.models import (
REQUIRED_PRICING_FIELDS,
_row_to_model,
list_models,
)
from ..payment.rates import BILLABLE_PRICING_FIELDS, coerce_rate
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import fetch_all_balances, send_token, token_mint_url
from . import vault
@@ -29,6 +34,7 @@ from .db import (
from .db import (
store_cashu_transaction_with_retry as store_cashu_transaction,
)
from .exceptions import json_compliant
from .log_manager import log_manager
from .logging import get_logger
from .provider_slugs import allocate_unique_provider_slug
@@ -519,6 +525,38 @@ class ModelCreate(BaseModel):
enabled: bool = True
forwarded_model_id: str | None = None
@field_validator("pricing")
@classmethod
def _validate_pricing(cls, value: dict[str, object]) -> dict[str, object]:
"""Reject a rate that is malformed, non-finite, negative or not there.
A present-but-invalid rate would otherwise slip through: a non-numeric
string coerces to $0 on the read path (an unpriced-looking row), while a
negative or ``NaN``/``inf`` value is truthy and reads back as a real
price, so the model could be enabled and bill a nonsensical amount.
Surfacing a 422 reports the client bug as a client bug instead of
persisting it. Numeric strings (``"0.000005"``) stay valid, and so does
an omitted auxiliary rate — the stored JSON accepts both.
"""
for field in BILLABLE_PRICING_FIELDS:
if field not in value:
# ``dict.get`` cannot tell this from an explicit ``null``, so
# both were skipped and a row that ``Pricing`` cannot parse was
# written — and then raised out of the response that reads it
# back, after the row had been committed.
if field in REQUIRED_PRICING_FIELDS:
raise ValueError(f"{field} is required")
continue
# The shared coercion also absorbs the OverflowError an oversized
# integer raises, which pydantic does not convert into a validation
# error — unhandled it escaped as a 500 for a bad client value.
if coerce_rate(value[field]) is None:
raise ValueError(
f"{field} must be a finite, non-negative number, "
f"got {value[field]!r}"
)
return value
def _normalize_forwarded_model_id(value: str | None) -> str | None:
if value is None:
@@ -645,9 +683,13 @@ async def get_provider_model(provider_id: str, model_id: str) -> dict[str, objec
raise HTTPException(
status_code=404, detail="Model not found for this provider"
)
return _row_to_model(
row, apply_provider_fee=False, provider_fee=provider.provider_fee
).dict() # type: ignore
# Same duty as the listing this view is opened from: a stored rate that
# is not a usable number must be shown as it is, not encoded as `null`.
return json_compliant( # type: ignore[return-value]
_row_to_model(
row, apply_provider_fee=False, provider_fee=provider.provider_fee
).dict()
)
@admin_router.delete(
@@ -1237,8 +1279,13 @@ async def get_provider_models(provider_id: str) -> dict[str, object]:
"provider_type": provider.provider_type,
"base_url": provider.base_url,
},
"db_models": [m.dict() for m in db_models],
"remote_models": [m.dict() for m in filtered_remote_models],
# This listing includes disabled models, so it is the one view that
# still carries a row the served-catalog backstop holds back —
# including one whose stored rate is not a usable number. The
# encoder would report that rate as `null`, indistinguishable from a
# missing one; show the operator the value that needs fixing.
"db_models": [json_compliant(m.dict()) for m in db_models],
"remote_models": [json_compliant(m.dict()) for m in filtered_remote_models],
}
+43
View File
@@ -1,4 +1,8 @@
import math
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .logging import get_logger
@@ -71,6 +75,45 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
return JSONResponse(status_code=status_code, content=content)
def json_compliant(value: object) -> object:
"""Render non-finite floats as text so a reply carrying them can serialize.
``json`` parses the bare ``NaN``/``Infinity``/``-Infinity`` literals into
real floats, so a request body — and a stored row written from one — may
hold one anywhere. ``JSONResponse`` encodes with ``allow_nan=False`` and
raises on them, which would turn a reply that merely *quotes* the offending
value into a 500.
"""
if isinstance(value, float) and not math.isfinite(value):
return repr(value)
if isinstance(value, dict):
return {key: json_compliant(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [json_compliant(item) for item in value]
return value
async def validation_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""Answer a request-validation failure with a 422 that always serializes.
Pydantic echoes the rejected value back in each error's ``input`` field. A
non-finite float there breaks the encoder, so the 422 escapes as a 500 and
reports a client's bad rate as a server fault.
"""
request_id = getattr(request.state, "request_id", "unknown")
errors = exc.errors() if isinstance(exc, RequestValidationError) else []
return JSONResponse(
status_code=422,
content={
"detail": json_compliant(jsonable_encoder(errors)),
"request_id": request_id,
},
)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle general exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
+7 -1
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -33,7 +34,11 @@ from ..upstream.litellm_routing import configure_litellm
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .exceptions import (
general_exception_handler,
http_exception_handler,
validation_exception_handler,
)
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
@@ -289,6 +294,7 @@ app.add_middleware(LoggingMiddleware)
# Add exception handlers
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
+36 -17
View File
@@ -6,6 +6,7 @@ from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.settings import settings
from .price import sats_usd_price
from .rates import coerce_rate, is_usable_rate
from .usage import normalize_usage, parse_token_count
if TYPE_CHECKING:
@@ -201,13 +202,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 +269,14 @@ async def calculate_cost(
else:
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
if not (input_rate and output_rate):
# Truthiness is not the question: `NaN` and a negative rate are both truthy
# and sailed past this gate into the token math, while a rate of zero is a
# price — free — and reading it as a missing one charged the whole
# reservation for a request the model serves for nothing.
rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
if not all(is_usable_rate(rate) for rate in rates):
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 +286,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 +322,25 @@ async def calculate_cost(
def _coerce_usd(value: object) -> float:
"""Coerce a value to USD float, handling various formats safely."""
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):
return 0.0
"""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.
"""
# A cost figure is coerced exactly like a rate; only the way an unusable one
# is reported differs. A negative is rejected here, where the previous
# `max(0.0, …)` clamped it.
amount = coerce_rate(value)
return amount if amount is not None else 0.0
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
+77 -21
View File
@@ -12,6 +12,7 @@ from ..core.db import ModelRow, UpstreamProviderRow, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
from .rates import BILLABLE_PRICING_FIELDS, coerce_rate, is_usable_rate
logger = get_logger(__name__)
@@ -58,6 +59,26 @@ class Pricing(BaseModel):
max_cost: float = 0.0 # in sats not msats
# The rates ``Pricing`` declares without a default, derived from the model so the
# two cannot drift. A payload that omits one writes a row that will not parse.
REQUIRED_PRICING_FIELDS = tuple(
name for name, field in Pricing.__fields__.items() if field.required
)
def has_usable_pricing(pricing: Pricing) -> bool:
"""True if every billable rate is a number a request could be billed on.
Free is usable — a rate of zero is a real price. This asks only whether the
price is well-formed. One unusable rate disqualifies the whole price even
alongside a valid one, since a request can bill on the bad field: a positive
``completion`` must not hide a negative ``prompt``.
"""
return all(
is_usable_rate(getattr(pricing, field)) for field in BILLABLE_PRICING_FIELDS
)
class TopProvider(BaseModel):
context_length: int | None = None
max_completion_tokens: int | None = None
@@ -144,18 +165,17 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
"""Check if model has valid pricing (usable rates, and not free)."""
pricing = model.get("pricing", {})
if not pricing:
return False
try:
prompt = float(pricing.get("prompt", 0))
completion = float(pricing.get("completion", 0))
except (ValueError, TypeError):
return False
if prompt < 0 or completion < 0:
# Coercion runs before the both-zero test below, which `NaN` would defeat
# on its own — and one entry the coercion chokes on must not unwind the
# whole fetch, which once cost the node an entire upstream catalog.
prompt = coerce_rate(pricing.get("prompt", 0))
completion = coerce_rate(pricing.get("completion", 0))
if prompt is None or completion is None:
return False
if prompt == 0 and completion == 0:
@@ -309,21 +329,57 @@ async def list_models(
rows = (await session.exec(query)).all() # type: ignore
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
return [
_row_to_model(
r,
apply_provider_fee=apply_fees,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
)
for r in rows
if include_disabled
or (
models: list[Model] = []
for r in rows:
if not include_disabled and not (
r.upstream_provider_id in providers_by_id
and providers_by_id[r.upstream_provider_id].enabled
)
]
):
continue
try:
model = _row_to_model(
r,
apply_provider_fee=apply_fees,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
)
except Exception as e:
# Stored pricing/architecture is JSON from whatever wrote the row, so
# a legacy import or foreign writer can leave a field that will not
# parse. Converting inside this loop meant one such row raised out of
# the whole listing and the node advertised nothing at all. Drop the
# row we cannot read — it is unservable either way — and keep serving
# the rest.
logger.warning(
"Skipping model row that could not be read",
extra={
"model_id": r.id,
"upstream_provider_id": r.upstream_provider_id,
"error": str(e),
"error_type": type(e).__name__,
},
)
continue
# Served-map backstop for legacy rows and writers that bypass the admin
# edge: a negative or non-finite rate is not a price. Serving one
# advertises a rate the cost calculation cannot bill on, so the request
# falls through to the flat maximum reservation — or, if the rate is
# negative, bills an amount settlement credits back to the caller.
# ``include_disabled`` is the operator's listing, which must keep showing
# the row so it can be repaired.
if not include_disabled and not has_usable_pricing(model.pricing):
logger.warning(
"Withholding model with an unusable stored rate from the catalog",
extra={
"model_id": r.id,
"upstream_provider_id": r.upstream_provider_id,
},
)
continue
models.append(model)
return models
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
+35 -13
View File
@@ -5,12 +5,37 @@ import httpx
from ..core import get_logger
from ..core.settings import settings
from .rates import coerce_rate
logger = get_logger(__name__)
BTC_USD_PRICE: float | None = None
SATS_USD_PRICE: float | None = None
SATS_PER_BTC = 100_000_000
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 quote is stricter than a billable rate — it must be
positive, and positive *after* the sats conversion the node prices in: a
subnormal quote survives every guard here and still underflows to a zero
sats price, which then divides by zero on every model's rate.
"""
price = coerce_rate(raw)
if price is None or price <= 0 or price / SATS_PER_BTC <= 0:
logger.warning(
"Unusable price quote — ignoring this exchange",
extra={"exchange": exchange, "quote": repr(raw)},
)
return None
return price
async def _kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Kraken API."""
@@ -18,10 +43,11 @@ async def _kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
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 +65,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 +84,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={
@@ -123,7 +145,7 @@ async def _update_prices() -> None:
)
return
BTC_USD_PRICE = btc_price
SATS_USD_PRICE = btc_price / 100_000_000
SATS_USD_PRICE = btc_price / SATS_PER_BTC
def btc_usd_price() -> float:
+71
View File
@@ -0,0 +1,71 @@
"""The one definition of a billable rate, with no dependencies of its own.
A rate reaches the node from an upstream catalog, the LiteLLM cost map, an
operator's admin edit, a legacy database row and the BTC/USD feed. Each of those
readers needs the same two questions answered — is this value a rate at all, and
is it a rate a request can be billed on — so both answers live here, in a module
that imports nothing from the package. Every guard then shares one definition
instead of drifting, and no caller needs a deferred import to reach it.
"""
import math
# The rates a request can bill on. Derived fields (``max_*_cost``) are excluded
# — they are computed carriers, not charged rates. One definition, shared by the
# admin write edge and the served/routed guards, so they all cover the same set.
BILLABLE_PRICING_FIELDS = (
"prompt",
"completion",
"request",
"image",
"web_search",
"internal_reasoning",
"input_cache_read",
"input_cache_write",
)
def is_usable_rate(rate: float) -> bool:
"""True if a single billable rate is a number a request could be billed on.
The one definition of a usable rate, so every guard that asks the question
answers it identically. A rate qualifies only when it is finite and
non-negative; zero is usable (it means "free", which is a real price) but
``NaN``, ``±inf`` and negatives are not prices at all.
Non-finite: ``inf > 0`` is True, so an infinite rate reads as chargeable and
would be served, routed and billed as ``inf``; ``NaN`` poisons every total it
enters and defeats ordinary comparisons, since ``NaN > 0``, ``NaN < 0`` and
``NaN == 0`` are all False. Negative: a negative rate produces a negative
cost, which the settlement path subtracts from the balance — it pays the
caller to make requests. Both reach a stored row from upstream catalogs as
well as the admin edge (``json.loads`` accepts the bare ``NaN``/``Infinity``
literals and overflows ``1e999`` to ``inf``).
This is the rationale for every guard that calls it; the call sites say what
they do with the answer, not why the answer matters.
"""
return math.isfinite(rate) and rate >= 0.0
def coerce_rate(value: object) -> float | None:
"""Coerce a value from outside the node to a usable rate, or ``None``.
The one coercion, shared by every reader of a rate the node did not compute
itself: an upstream catalog, the LiteLLM cost map, the exchange feed and the
admin write edge. Each of them was parsing for itself, and they disagreed —
which is how a boolean became a price on some paths and not others.
A boolean is rejected outright: it is a change of shape, not a rate, and
Python would make ``True`` a finite, positive ``1.0`` that passes every
numeric guard downstream — a dollar per token. A numeric string is accepted,
because feeds report prices as strings. An oversized integer raises
``OverflowError`` rather than ``ValueError``, so that is caught too.
"""
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return None
try:
rate = float(value)
except (TypeError, ValueError, OverflowError):
return None
return rate if is_usable_rate(rate) else None
+15 -15
View File
@@ -17,6 +17,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from ..payment.rates import coerce_rate
@dataclass
class ResolvedPricing:
@@ -65,11 +67,8 @@ def estimate_context_length(model_id: str) -> int:
def _as_float(value: object) -> float | None:
"""OpenRouter reports prices as strings; coerce, ``None`` if unparseable."""
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
"""OpenRouter reports prices as strings; coerce, ``None`` if not a real rate."""
return coerce_rate(value)
def _as_int(value: object) -> int | None:
@@ -78,23 +77,24 @@ def _as_int(value: object) -> int | None:
def _from_litellm(model_id: str) -> ResolvedPricing | None:
# Lazy import so the resolver stays import-light and shares the exact
# lookup semantics used by cache-rate backfill.
# Lazy import so the resolver shares the exact lookup semantics used by
# cache-rate backfill without importing the models module at load time.
from ..payment.models import litellm_cost_entry
info = litellm_cost_entry(model_id)
if info is None:
return None
prompt = info.get("input_cost_per_token")
completion = info.get("output_cost_per_token")
if not isinstance(prompt, (int, float)) or not isinstance(completion, (int, float)):
prompt = coerce_rate(info.get("input_cost_per_token"))
completion = coerce_rate(info.get("output_cost_per_token"))
if prompt is None or completion is None:
return None
# A both-zero entry is litellm listing a model without a real price (free
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
# the model for free. Reject it (and any negative) so the caller falls
# through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
if prompt < 0 or completion < 0 or (prompt == 0 and completion == 0):
# the model for free. Reject it so the caller falls through, mirroring
# async_fetch_openrouter_models' _has_valid_pricing. Coercion runs first:
# `NaN` would defeat this guard on its own, every comparison being False.
if prompt == 0 and completion == 0:
return None
input_modalities = ["text"]
@@ -102,8 +102,8 @@ def _from_litellm(model_id: str) -> ResolvedPricing | None:
input_modalities.append("image")
return ResolvedPricing(
prompt=float(prompt),
completion=float(completion),
prompt=prompt,
completion=completion,
# max_input_tokens is the context window; max_tokens is litellm's
# completion cap (it tracks max_output_tokens for ~94% of models), so
# it is never a context source. A missing window falls to the id-based
+4
View File
@@ -516,6 +516,10 @@ async def integration_app(
# Copy all routes from the main app
test_app.router = app.router
# ...and its exception handlers, so a request that fails here fails the way
# it would in production rather than escaping as a bare exception.
test_app.exception_handlers.update(app.exception_handlers)
# Override the get_session dependency
async def override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield integration_session
@@ -0,0 +1,469 @@
"""Admin write edge: a rate that is not a number never becomes a stored price.
A billable rate is usable only when it is finite and non-negative. The admin
model endpoints are an entry point for rates the node will later bill on, and
they accept whatever a client sends: ``json`` parses the bare ``NaN``/
``Infinity`` literals into real floats and overflows ``1e999`` to ``inf``, a
non-numeric string coerced silently to ``$0``, and a negative rate is truthy so
it read back as a chargeable price that bills a negative amount.
These tests assert the edge answers a malformed rate with a 422 — a client bug
reported as a client bug — rather than persisting it or failing as a 500, and
that the operator can still open the listing that shows the row needing repair.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import pytest
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.admin import admin_sessions
from routstr.core.db import ModelRow, UpstreamProviderRow
from routstr.proxy import reinitialize_upstreams
def _admin_headers() -> dict[str, str]:
token = "test-admin-rate-validation-token"
admin_sessions[token] = int(
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
)
return {"Authorization": f"Bearer {token}"}
def _pricing(**overrides: object) -> dict[str, object]:
pricing: dict[str, object] = {
"prompt": 1.4e-7,
"completion": 2.8e-7,
"request": 0.0,
"image": 0.0,
"web_search": 0.0,
"internal_reasoning": 0.0,
"input_cache_read": 0.0,
"input_cache_write": 0.0,
}
pricing.update(overrides)
return pricing
def _payload(
provider_id: int,
*,
model_id: str = "rate-model",
pricing: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"id": model_id,
"name": "Rate Model",
"description": "d",
"created": 0,
"context_length": 128000,
"architecture": {
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
},
"pricing": pricing if pricing is not None else _pricing(),
"per_request_limits": None,
"top_provider": None,
"upstream_provider_id": provider_id,
"canonical_slug": None,
"alias_ids": [],
"enabled": True,
"forwarded_model_id": model_id,
}
async def _make_provider(session: AsyncSession) -> int:
provider = UpstreamProviderRow(
provider_type="generic",
base_url="https://rate-upstream.example/v1",
api_key="test-key",
provider_fee=1.0,
)
session.add(provider)
await session.commit()
await session.refresh(provider)
await reinitialize_upstreams()
assert provider.id is not None
return provider.id
def _raw_model_body(provider_id: int, model_id: str, prompt_literal: str) -> str:
"""A request body built as text, so it can carry a literal ``json`` accepts
but Python's own encoder would refuse to produce."""
return (
f'{{"id": "{model_id}", "name": "raw", "description": "d", "created": 0,'
' "context_length": 8192, "architecture": {"modality": "text"},'
f' "pricing": {{"prompt": {prompt_literal}, "completion": 2.8e-7}},'
f' "upstream_provider_id": {provider_id}}}'
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_negative_price_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""A negative rate is not a valid price — accepting it would persist a row
that bills a negative amount, which settlement subtracts from the balance.
Being truthy, it also reads back as a chargeable price. Reject at the edge
rather than silently storing it."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(provider_id, model_id="neg-price", pricing=_pricing(prompt=-1.0)),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("neg-price", provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_malformed_price_string_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""A present non-numeric rate is a client bug: it coerces to ``$0`` on the
read path, producing an unpriced-looking row indistinguishable from a
deliberate free price. Surface it as a 422 instead of accepting it."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(
provider_id, model_id="bad-price", pricing=_pricing(prompt="oops")
),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("bad-price", provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_boolean_price_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""A JSON ``true`` coerces to a finite, positive ``1.0`` — a dollar per
token — so it passes every numeric guard. The write edge asks the same
coercion the catalog readers do, and answers a 422."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(
provider_id, model_id="bool-price", pricing=_pricing(prompt=True)
),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("bool-price", provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.parametrize(
("model_id", "pricing"),
[
("null-prompt", _pricing(prompt=None)),
("null-aux-rate", _pricing(image=None)),
("no-prompt", {k: v for k, v in _pricing().items() if k != "prompt"}),
],
ids=["null-required", "null-auxiliary", "absent-required"],
)
async def test_a_rate_that_is_not_there_is_rejected(
model_id: str,
pricing: dict[str, object],
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""A rate given as ``null``, or a required rate left out, is not a price.
``dict.get`` cannot tell the two apart and skipped both, so a row
``Pricing`` cannot parse was committed and the response that reads it back
raised.
"""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(provider_id, model_id=model_id, pricing=pricing),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, (model_id, provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_an_absent_auxiliary_rate_is_still_accepted(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""Only ``prompt`` and ``completion`` are required; the rest carry defaults,
and a payload that omits them must still be accepted."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(
provider_id,
model_id="lean-price",
pricing={"prompt": 1.4e-7, "completion": 2.8e-7},
),
)
assert resp.status_code == 200
assert await integration_session.get(ModelRow, ("lean-price", provider_id))
@pytest.mark.integration
@pytest.mark.asyncio
async def test_numeric_string_price_is_still_accepted(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""The stored pricing JSON has always accepted numeric strings, and the UI
round-trips rates through text fields. Rejecting a *malformed* rate must not
also reject a well-formed one that arrives spelled as a string."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(
provider_id, model_id="string-price", pricing=_pricing(prompt="0.000005")
),
)
assert resp.status_code == 200
row = await integration_session.get(ModelRow, ("string-price", provider_id))
assert row is not None
assert json.loads(row.pricing)["prompt"] == "0.000005"
def test_non_finite_price_is_rejected_by_the_write_model() -> None:
"""``NaN``/``±inf`` are not billable rates: the carrier every write endpoint
shares must reject them before they can be persisted and read back as a
chargeable price."""
from pydantic import ValidationError
from routstr.core.admin import ModelCreate
for bad in (float("nan"), float("inf"), float("-inf")):
with pytest.raises(ValidationError):
ModelCreate.model_validate(
_payload(1, model_id="nonfinite", pricing=_pricing(prompt=bad))
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_oversized_integer_price_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""A JSON integer too large for a float is a client bug, not a server fault.
``float()`` raises ``OverflowError`` for it, and pydantic converts only
``ValueError``/``AssertionError`` into validation errors, so it escaped the
edge as a 500. It must be answered with the same 422 as every other
unusable rate.
"""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers={**_admin_headers(), "Content-Type": "application/json"},
content=_raw_model_body(provider_id, "huge-price", "9" * 400),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("huge-price", provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_non_finite_literal_price_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""A bare ``Infinity``/``NaN`` literal gets the same 422 as any other rate.
``json`` accepts both literals, so the edge sees a real float and rejects
it — but pydantic echoes the offending value back in the error's ``input``
field, and the response encoder runs with ``allow_nan=False``. Serializing
that reply raised "Out of range float values are not JSON compliant", so the
422 escaped as a 500 and reported a client's bad rate as a server fault.
"""
provider_id = await _make_provider(integration_session)
for literal in ("Infinity", "-Infinity", "NaN"):
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers={**_admin_headers(), "Content-Type": "application/json"},
content=_raw_model_body(provider_id, "odd-price", literal),
)
assert resp.status_code == 422, literal
assert (
await integration_session.get(ModelRow, ("odd-price", provider_id)) is None
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_non_finite_literal_price_is_rejected_in_batch_override(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""The batch path shares the same carrier, so it must answer 422 too."""
provider_id = await _make_provider(integration_session)
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/batch-override",
headers={**_admin_headers(), "Content-Type": "application/json"},
content=(
'{"models": ['
+ _raw_model_body(provider_id, "odd-batch", "Infinity")
+ "]}"
),
)
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("odd-batch", provider_id)) is None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_model_listing_shows_a_non_finite_stored_rate(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""The operator must be able to see the rate that needs fixing.
The admin listing is the one view that still carries a row the served
catalog holds back, and its encoder rendered a stored ``Infinity`` as
``null`` — indistinguishable from a rate the row never carried.
"""
provider_id = await _make_provider(integration_session)
integration_session.add(
ModelRow(
id="inf-rate",
name="inf-rate",
description="d",
created=0,
context_length=8192,
architecture=json.dumps(
{
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
}
),
pricing=json.dumps({"prompt": float("inf"), "completion": 2e-06}),
upstream_provider_id=provider_id,
enabled=True,
forwarded_model_id="inf-rate",
)
)
await integration_session.commit()
resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
)
assert resp.status_code == 200
listed = {m["id"]: m for m in resp.json()["db_models"]}
assert listed["inf-rate"]["pricing"]["prompt"] == "inf"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_malformed_auxiliary_rate_is_rejected(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""Validation spans every billable rate, not just the token rates.
``prompt``/``completion`` are the rates most prices are built from, but the
request, image, search, reasoning and cache rates are billed too. A negative
or non-finite value in any of them is the same defect and must be answered
the same way.
"""
provider_id = await _make_provider(integration_session)
for field, bad in (
("request", -1.0),
("image", -0.5),
("web_search", float("inf")),
("internal_reasoning", float("nan")),
("input_cache_read", -1e-06),
("input_cache_write", float("-inf")),
("completion", -1.0),
):
resp = await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
headers=_admin_headers(),
json=_payload(
provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad})
),
)
assert resp.status_code == 422, field
assert (
await integration_session.get(ModelRow, ("aux-rate", provider_id)) is None
), field
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_single_model_shows_a_non_finite_stored_rate(
integration_client: AsyncClient, integration_session: AsyncSession
) -> None:
"""The single-model view answers like the listing it is opened from.
It is the other view of a row the served-catalog backstop holds back, so it
has the same duty to name the rate that needs fixing rather than rendering
it as ``null``.
"""
provider_id = await _make_provider(integration_session)
integration_session.add(
ModelRow(
id="inf-one",
name="inf-one",
description="d",
created=0,
context_length=8192,
architecture=json.dumps(
{
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
}
),
pricing=json.dumps({"prompt": float("inf"), "completion": 2e-06}),
upstream_provider_id=provider_id,
enabled=True,
forwarded_model_id="inf-one",
)
)
await integration_session.commit()
resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}/models/inf-one",
headers=_admin_headers(),
)
assert resp.status_code == 200
assert resp.json()["pricing"]["prompt"] == "inf"
@@ -0,0 +1,255 @@
"""The served catalog is the last guard between a stored row and a charge.
Stored pricing is JSON written by whatever produced the row — an upstream
import, an operator, a legacy migration, or a foreign writer that never passed
the admin edge. So the read path cannot assume a stored rate is a number: it
must decline to serve a row it cannot bill on, and it must survive a row it
cannot read at all rather than taking the whole catalog down with it.
The admin listing is deliberately exempt: it includes disabled models and is the
one view that still shows the operator the row that needs repair.
"""
from __future__ import annotations
import json
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ModelRow, UpstreamProviderRow
from routstr.payment.models import list_models
_ARCHITECTURE = json.dumps(
{
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
}
)
async def _make_provider(session: AsyncSession) -> int:
provider = UpstreamProviderRow(
provider_type="generic",
base_url="https://served-upstream.example/v1",
api_key="test-key",
provider_fee=1.0,
)
session.add(provider)
await session.commit()
await session.refresh(provider)
assert provider.id is not None
return provider.id
async def _insert_row(
session: AsyncSession,
provider_id: int,
*,
model_id: str,
pricing: dict[str, object],
) -> None:
session.add(
ModelRow(
id=model_id,
name=model_id,
description="d",
created=0,
context_length=8192,
architecture=_ARCHITECTURE,
pricing=json.dumps(pricing),
upstream_provider_id=provider_id,
enabled=True,
forwarded_model_id=model_id,
)
)
await session.commit()
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.parametrize(
"bad_rate",
[float("nan"), float("inf"), -1.0],
ids=["nan", "inf", "negative"],
)
async def test_served_catalog_excludes_a_malformed_stored_rate(
integration_session: AsyncSession, bad_rate: float
) -> None:
"""A stored rate that is not a number must not be advertised.
Zero is a real price and a free model is servable, but a negative or
non-finite rate is not a price at all: serving it advertises a rate the cost
calculation cannot bill on, so every request falls through to the flat
maximum reservation — or, for a negative rate, bills an amount settlement
credits back to the caller.
"""
provider_id = await _make_provider(integration_session)
await _insert_row(
integration_session,
provider_id,
model_id="good",
pricing={"prompt": 1e-06, "completion": 2e-06},
)
await _insert_row(
integration_session,
provider_id,
model_id="bad-rate",
pricing={"prompt": bad_rate, "completion": 2e-06},
)
served = {m.id for m in await list_models(integration_session, provider_id)}
assert served == {"good"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_free_stored_price_is_still_served(
integration_session: AsyncSession,
) -> None:
"""Zero is a real price. Rejecting malformed rates must not also drop a row
priced at zero, which is a free model and not a broken one."""
provider_id = await _make_provider(integration_session)
await _insert_row(
integration_session,
provider_id,
model_id="free",
pricing={"prompt": 0.0, "completion": 0.0},
)
served = {m.id for m in await list_models(integration_session, provider_id)}
assert served == {"free"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_listing_still_shows_a_malformed_stored_rate(
integration_session: AsyncSession,
) -> None:
"""The operator has to be able to see the row that needs fixing.
The backstop keeps a malformed row out of the *served* catalog. The listing
that includes disabled models is the one view where the row must still
appear, or the operator loses the ability to repair it.
"""
provider_id = await _make_provider(integration_session)
await _insert_row(
integration_session,
provider_id,
model_id="bad-rate",
pricing={"prompt": -1.0, "completion": 2e-06},
)
listed = {
m.id
for m in await list_models(
integration_session, provider_id, include_disabled=True
)
}
assert listed == {"bad-rate"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_one_unreadable_stored_price_does_not_blank_the_catalog(
integration_session: AsyncSession,
) -> None:
"""A single unparseable row must cost that row, not every model on the node.
Stored pricing is JSON written by whatever produced the row, so a
non-numeric rate is reachable from a legacy import or a foreign writer.
Parsing it raises out of the row-to-model conversion, and because the
conversion ran inside the catalog loop the exception took the whole listing
with it — one bad row and the node advertised nothing at all.
"""
provider_id = await _make_provider(integration_session)
await _insert_row(
integration_session,
provider_id,
model_id="good",
pricing={"prompt": 1e-06, "completion": 2e-06},
)
await _insert_row(
integration_session,
provider_id,
model_id="unreadable",
pricing={"prompt": "not-a-number", "completion": 2e-06},
)
served = {m.id for m in await list_models(integration_session, provider_id)}
assert served == {"good"}
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.parametrize(
"field",
[
"image",
"web_search",
"internal_reasoning",
"input_cache_read",
"input_cache_write",
],
)
async def test_served_catalog_excludes_a_malformed_auxiliary_rate(
integration_session: AsyncSession, field: str
) -> None:
"""The backstop covers every billable rate, not only the token rates.
A price whose ``prompt``/``completion`` are sound can still carry a
malformed request, image, search, reasoning or cache rate — the catalog
import filter never inspects those — and the request that hits one is billed
against it just the same.
"""
provider_id = await _make_provider(integration_session)
await _insert_row(
integration_session,
provider_id,
model_id="good",
pricing={"prompt": 1e-06, "completion": 2e-06},
)
await _insert_row(
integration_session,
provider_id,
model_id="bad-aux",
pricing={"prompt": 1e-06, "completion": 2e-06, field: -1.0},
)
served = {m.id for m in await list_models(integration_session, provider_id)}
assert served == {"good"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_negative_request_rate_is_clamped_on_read_and_still_served(
integration_session: AsyncSession,
) -> None:
"""``request`` is the one billable rate the row-to-model conversion repairs.
It clamps a negative stored ``request`` to zero before the price is built,
so the backstop never sees one and the row is served at a zero request rate
— money-safe, and the reason ``request`` is absent from the list of rates
above. Pinned here so that if the clamp goes, this rate joins that list
rather than quietly becoming the one unguarded field.
"""
provider_id = await _make_provider(integration_session)
await _insert_row(
integration_session,
provider_id,
model_id="neg-request",
pricing={"prompt": 1e-06, "completion": 2e-06, "request": -1.0},
)
served = await list_models(integration_session, provider_id)
assert [m.id for m in served] == ["neg-request"]
assert served[0].pricing.request == 0.0
+101
View File
@@ -954,3 +954,104 @@ def test_create_model_mappings_uppercase_prefixed_base_keeps_top_tier() -> None:
assert provider_map["qwen2.5-72b"][0] == (prefixed_cheap, prefixed_provider)
assert unique_models["qwen2.5-72b"].upstream_provider_id == "prefixed"
def test_create_model_mappings_excludes_a_malformed_price() -> None:
"""A rate that is not a number must not be routable.
A negative or non-finite rate reads as a real price to every truthiness
check, so the candidate was built into the map and served. The cost
calculation cannot price on such a rate, so every request on the model fell
through to the flat maximum reservation — or, for a negative rate, billed a
negative amount that settlement credits back to the caller.
"""
healthy = create_test_model("healthy-model")
for bad_rate in (float("nan"), float("inf"), -1.0):
broken = create_test_model("broken-model", prompt_price=bad_rate)
provider = create_test_provider(
"custom",
"https://custom.example/v1",
db_id=1,
models=[broken, healthy],
)
_, provider_map, unique_models = create_model_mappings(
upstreams=[provider],
overrides_by_key={},
disabled_model_keys=set(),
)
assert "broken-model" not in provider_map, bad_rate
assert "broken-model" not in unique_models, bad_rate
# One unroutable candidate must not cost the provider its other models.
assert "healthy-model" in provider_map, bad_rate
def test_create_model_mappings_excludes_an_override_with_a_malformed_price(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An override row carrying a malformed rate is unroutable too.
An override replaces the discovered model's price, so a provider whose
catalog is sound still routes at whatever the row says. The guard has to sit
after the override is applied, not before it.
"""
discovered = create_test_model("shared-model")
provider = create_test_provider(
"custom", "https://custom.example/v1", db_id=3, models=[discovered]
)
override_model = create_test_model("shared-model", prompt_price=float("-inf"))
monkeypatch.setattr(
"routstr.payment.models._row_to_model",
lambda *args, **kwargs: override_model,
)
override_row = SimpleNamespace(
id="shared-model", upstream_provider_id=3, enabled=True
)
_, provider_map, unique_models = create_model_mappings(
upstreams=[provider],
overrides_by_key={("shared-model", 3): (override_row, 1.0)},
disabled_model_keys=set(),
)
assert "shared-model" not in provider_map
assert "shared-model" not in unique_models
def test_create_model_mappings_survives_an_unreadable_override_row(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One row that cannot be read must not empty the whole routing map.
Converting an override while walking a provider's catalog let the exception
unwind the entire map build: the node came up routing nothing. The sibling
loop over override-only rows already skips and logs such a row.
"""
broken = create_test_model("broken-model")
healthy = create_test_model("healthy-model")
provider = create_test_provider(
"custom",
"https://custom.example/v1",
db_id=5,
models=[broken, healthy],
)
def raising_row_to_model(row: Any, *args: Any, **kwargs: Any) -> Model:
raise ValueError("value is not a valid float")
monkeypatch.setattr("routstr.payment.models._row_to_model", raising_row_to_model)
override_row = SimpleNamespace(
id="broken-model", upstream_provider_id=5, enabled=True
)
_, provider_map, unique_models = create_model_mappings(
upstreams=[provider],
overrides_by_key={("broken-model", 5): (override_row, 1.0)},
disabled_model_keys=set(),
)
assert "broken-model" not in provider_map
assert "healthy-model" in provider_map
assert "healthy-model" in unique_models
+491
View File
@@ -0,0 +1,491 @@
"""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.
It reached the token math, which raises after the response was already
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(
("prompt", "completion", "expected_msats"),
[(0.0, 0.0, 0), (0.0, 2e-06, 1), (1e-06, 0.0, 1)],
ids=["free", "free-input", "free-output"],
)
@pytest.mark.asyncio
async def test_a_rate_of_zero_is_billed_as_free_not_as_missing(
prompt: float, completion: float, expected_msats: int
) -> None:
"""Zero is a price, and the request must be billed on it.
The gate that decides a model has no token pricing was a truthiness test, so
a free rate read as an absent one and the request was charged the whole
reservation instead — on a model priced at zero for that side, which is a
price the catalog serves and the router routes.
"""
model = _model(Pricing(prompt=prompt, completion=completion))
cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model)
assert isinstance(cost, CostData)
assert cost.total_msats == expected_msats
@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 poisoned the split,
and the request fell through to token estimation for a fraction of it.
"""
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]:
# A quote given as an exception stands for a response body that never
# produced one: an exchange answering with an HTML error page raises
# out of `.json()` before any price is read.
quote = next(iter(self._payload.values()))
if isinstance(quote, BaseException):
raise quote
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:
quote = self._quotes["kraken"]
if isinstance(quote, BaseException):
return _ExchangeResponse({"error": quote})
return _ExchangeResponse({"result": {"XXBTZUSD": {"c": [quote]}}})
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 what it collects, so an unusable quote does
not merely join the sample — it *wins*. 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.
"""
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_an_underflowing_exchange_quote_does_not_set_the_node_price(
refresh_price_with: Any,
) -> None:
"""A quote too small to survive the sats conversion is not a price.
``1e-320`` is positive, so it passes the guards and wins the ``min()``, but
the node prices in sats and ``1e-320 / 100_000_000`` underflows to ``0.0``
— a zero sats price divides by zero on every model's rate.
"""
from routstr.payment.price import btc_usd_price
await refresh_price_with(
{"kraken": "1e-320", "coinbase": "100000.0", "binance": "100000.0"}
)
assert btc_usd_price() == pytest.approx(100000.0)
@pytest.mark.asyncio
async def test_an_unreadable_exchange_response_drops_only_that_quote(
refresh_price_with: Any,
) -> None:
"""An exchange whose response never yields a quote costs one quote.
The price is aggregated across three exchanges so that one of them having a
bad day is survivable; unhandled, the raise aborted the whole aggregation.
"""
from routstr.payment.price import btc_usd_price
await refresh_price_with(
{
"kraken": ValueError("Expecting value: line 1 column 1 (char 0)"),
"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)
# ---------------------------------------------------------------------------
# Catalog ingest — a malformed rate must never become a stored price
# ---------------------------------------------------------------------------
class _CatalogResponse:
def __init__(self, payload: dict[str, Any]) -> None:
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, Any]:
return self._payload
class _CatalogClient:
"""Stands in for ``httpx.AsyncClient`` against the OpenRouter catalog."""
def __init__(self, models: list[dict[str, Any]]) -> None:
self._models = models
async def __aenter__(self) -> "_CatalogClient":
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def get(self, url: str, timeout: int | None = None) -> _CatalogResponse:
if url.endswith("/embeddings/models"):
return _CatalogResponse({"data": []})
return _CatalogResponse({"data": self._models})
def _catalog_entry(model_id: str, pricing: dict[str, Any]) -> dict[str, Any]:
return {"id": model_id, "name": model_id, "pricing": pricing}
def _patch_openrouter_catalog(models: list[dict[str, Any]]) -> Any:
return patch(
"routstr.payment.models.httpx.AsyncClient",
lambda *args, **kwargs: _CatalogClient(models),
)
@pytest.mark.parametrize(
"bad_rate",
[float("nan"), float("inf"), float("-inf")],
ids=["nan", "inf", "negative-inf"],
)
@pytest.mark.asyncio
async def test_non_finite_catalog_rate_is_not_imported(bad_rate: float) -> None:
"""A non-finite rate in the upstream catalog is junk, not a price.
``json.loads`` accepts the bare ``NaN``/``Infinity`` literals and overflows
``1e999`` to ``inf``, so an upstream feed can deliver one. The import filter
rejects a negative and a both-zero price, but every comparison with ``NaN``
is False and ``inf`` reads as a large positive, so both sailed through and
became a stored price the node would advertise and bill on.
"""
with _patch_openrouter_catalog(
[
_catalog_entry("bad", {"prompt": bad_rate, "completion": "0.000002"}),
_catalog_entry("good", {"prompt": "0.000001", "completion": "0.000002"}),
]
):
from routstr.payment.models import async_fetch_openrouter_models
models = await async_fetch_openrouter_models()
assert [m["id"] for m in models] == ["good"]
@pytest.mark.asyncio
async def test_oversized_catalog_rate_does_not_empty_the_catalog() -> None:
"""An integer too large to be a float must cost one model, not all of them.
``float()`` raises ``OverflowError`` — not ``ValueError`` — for such a
value, so the coercion guard in the import filter did not catch it and the
exception unwound the whole fetch. The node then imported nothing at all
from an upstream whose catalog was fine apart from one entry.
"""
with _patch_openrouter_catalog(
[
_catalog_entry("bad", {"prompt": 10**400, "completion": 2}),
_catalog_entry("good", {"prompt": "0.000001", "completion": "0.000002"}),
]
):
from routstr.payment.models import async_fetch_openrouter_models
models = await async_fetch_openrouter_models()
assert [m["id"] for m in models] == ["good"]
@pytest.mark.asyncio
async def test_boolean_catalog_rate_is_not_imported() -> None:
"""A JSON ``true`` is a change of shape, not a price.
Python coerces it to a finite, positive ``1.0`` — a dollar per token — so it
passes every numeric guard and must be rejected before coercion.
"""
with _patch_openrouter_catalog(
[
_catalog_entry("bad", {"prompt": True, "completion": "0.000002"}),
_catalog_entry("good", {"prompt": "0.000001", "completion": "0.000002"}),
]
):
from routstr.payment.models import async_fetch_openrouter_models
models = await async_fetch_openrouter_models()
assert [m["id"] for m in models] == ["good"]
+202
View File
@@ -522,3 +522,205 @@ async def test_unresolvable_model_fails_closed(
for rec in caplog.records
if rec.levelno >= logging.WARNING
)
# ---------------------------------------------------------------------------
# rate validation — a malformed rate is not a resolved price, at any rung
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_non_finite_litellm_rate_is_not_a_resolved_price() -> None:
"""A non-finite entry in the cost map must not answer the resolution chain.
The litellm rung rejects negatives and a both-zero entry, but every
comparison with ``NaN`` is False and ``inf`` reads as a large positive, so
either would be reported as a resolved price — enabling the model at a rate
the node cannot bill on. Fail closed instead: the model imports disabled,
which is what "no source knows this price" already means here.
"""
payload = {
"data": [
{"id": "nan-priced-model", "object": "model", "owned_by": "mystery"},
]
}
cost_entry = {
"input_cost_per_token": float("nan"),
"output_cost_per_token": float("inf"),
"max_input_tokens": 8192,
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
with patch("routstr.payment.models.litellm_cost_entry", lambda _id: cost_entry):
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(
base_url="http://x"
).fetch_models()
model = _model_by_id(models, "nan-priced-model")
assert model.enabled is False
assert model.pricing.prompt == 0.0
assert model.pricing.completion == 0.0
@pytest.mark.asyncio
async def test_non_finite_openrouter_rate_is_not_a_resolved_price() -> None:
"""``float("Infinity")`` parses happily from a feed string, so the
OpenRouter rung's coercion accepted it and reported an infinite rate as a
resolved price. It is not a price; the model must import disabled."""
payload = {
"data": [
{"id": "or-nonfinite-xyz", "object": "model", "owned_by": "mystery"},
]
}
feed = [
{
"id": "or-nonfinite-xyz",
"pricing": {"prompt": "Infinity", "completion": "0.000002"},
"context_length": 8192,
}
]
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=feed)
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
model = _model_by_id(models, "or-nonfinite-xyz")
assert model.enabled is False
assert model.pricing.prompt == 0.0
assert model.pricing.completion == 0.0
@pytest.mark.asyncio
async def test_non_finite_openrouter_cache_rate_is_dropped_not_carried() -> None:
"""A malformed *cache* rate must cost the cache rate, not the model.
The catalog import filter only inspects prompt and completion, so an entry
with two sound token rates and an unusable ``input_cache_read`` reaches the
resolver intact. Carrying that rate through would price every cached input
token at ``inf``; dropping it falls back to the full input rate, which is
what a missing cache rate already means.
"""
payload = {
"data": [
{"id": "or-badcache-xyz", "object": "model", "owned_by": "mystery"},
]
}
feed = [
{
"id": "or-badcache-xyz",
"pricing": {
"prompt": "0.000001",
"completion": "0.000002",
"input_cache_read": "Infinity",
},
"context_length": 8192,
}
]
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=feed)
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
model = _model_by_id(models, "or-badcache-xyz")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(1e-06)
assert model.pricing.input_cache_read == 0.0
@pytest.mark.asyncio
async def test_negative_openrouter_cache_rate_is_dropped_not_carried() -> None:
"""A negative rate is as unusable as a non-finite one, and arrives the same way.
The coercion that reads a feed price rejected ``NaN``/``inf`` but returned a
negative unchanged, and the catalog import filter that would have caught one
inspects only prompt and completion. So a negative cache rate was the single
malformed value that still reached a stored price — where it prices cached
input tokens at a credit rather than a charge.
"""
payload = {
"data": [
{"id": "or-negcache-xyz", "object": "model", "owned_by": "mystery"},
]
}
feed = [
{
"id": "or-negcache-xyz",
"pricing": {
"prompt": "0.000001",
"completion": "0.000002",
"input_cache_read": "-0.0000005",
},
"context_length": 8192,
}
]
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=feed)
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
model = _model_by_id(models, "or-negcache-xyz")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(1e-06)
assert model.pricing.input_cache_read == 0.0
@pytest.mark.asyncio
async def test_boolean_litellm_rate_is_not_a_resolved_price() -> None:
"""``isinstance(True, int)`` is True, so a boolean passed the cost map's own
numeric check and resolved as a rate of ``1.0`` — a dollar per token."""
payload = {
"data": [
{"id": "bool-priced-model", "object": "model", "owned_by": "mystery"},
]
}
cost_entry = {
"input_cost_per_token": True,
"output_cost_per_token": 2e-06,
"max_input_tokens": 8192,
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
with patch("routstr.payment.models.litellm_cost_entry", lambda _id: cost_entry):
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(
base_url="http://x"
).fetch_models()
model = _model_by_id(models, "bool-priced-model")
assert model.enabled is False
assert model.pricing.prompt == 0.0
assert model.pricing.completion == 0.0
@pytest.mark.asyncio
async def test_boolean_openrouter_rate_is_not_a_resolved_price() -> None:
"""The same coercion reads a feed's ``true`` as a rate of ``1.0``; the model
must import disabled rather than priced at a dollar per token."""
payload = {
"data": [
{"id": "or-bool-xyz", "object": "model", "owned_by": "mystery"},
]
}
feed = [
{
"id": "or-bool-xyz",
"pricing": {"prompt": True, "completion": "0.000002"},
"context_length": 8192,
}
]
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=feed)
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
model = _model_by_id(models, "or-bool-xyz")
assert model.enabled is False
assert model.pricing.prompt == 0.0
assert model.pricing.completion == 0.0