mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Merge pull request #694 from jeroenubbink/rate-validation
fix(pricing): a rate that is not a number is not a price
This commit is contained in:
+39
-4
@@ -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
@@ -6,12 +6,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
|
||||
@@ -30,6 +35,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
|
||||
@@ -520,6 +526,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:
|
||||
@@ -646,9 +684,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(
|
||||
@@ -1238,8 +1280,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],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -61,6 +65,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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -508,6 +508,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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user