From 754f5d1a77353cea2b312684fb6bd151d4c537c9 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Tue, 25 Aug 2026 16:21:33 +0200 Subject: [PATCH] fix(admin): reject a malformed pricing rate at the write edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model write endpoints accepted any rate a client sent. A non-numeric string coerced to $0 on the read path, producing an unpriced-looking row that cannot be told from a deliberate free price; a negative or non-finite rate is truthy, so it read back as a real price and the model could be enabled and bill a nonsensical amount. Validate the pricing payload on the carrier all three write endpoints share and answer any present billable rate that is non-numeric, non-finite or negative with a 422. An oversized integer raises OverflowError, which pydantic does not convert into a validation error, so it is caught explicitly — it was reaching the row-to-model read-back and escaping as a 500 after the row had already been written. Numeric strings stay valid: the stored JSON accepts them and the UI round-trips rates through text fields. `BILLABLE_PRICING_FIELDS` names the rates a request can bill on, so the edge and the read-path guards cover the same set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM --- routstr/core/admin.py | 39 +++- routstr/payment/models.py | 15 ++ .../test_admin_pricing_rate_validation.py | 211 ++++++++++++++++++ 3 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_admin_pricing_rate_validation.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 2da3baf1..be58972c 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -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 ( + BILLABLE_PRICING_FIELDS, + _row_to_model, + is_usable_rate, + list_models, +) from ..proxy import refresh_model_maps, reinitialize_upstreams from ..wallet import fetch_all_balances, send_token, token_mint_url from . import vault @@ -520,6 +525,36 @@ 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 malformed, non-finite or negative billable rate at the edge. + + 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. Absent rates and numeric strings (``"0.000005"``) stay + valid — the stored JSON accepts both. + """ + for field in BILLABLE_PRICING_FIELDS: + raw = value.get(field) + if raw is None: + continue + if isinstance(raw, bool) or not isinstance(raw, (int, float, str)): + raise ValueError(f"{field} must be a non-negative number") + try: + rate = float(raw) + except (ValueError, OverflowError): + # An integer too large for a float raises OverflowError, which + # pydantic does not convert into a validation error — unhandled + # it escapes as a 500 for what is still a bad client value. + raise ValueError(f"{field} must be a number, got {raw!r}") + if not is_usable_rate(rate): + raise ValueError(f"{field} must be a finite, non-negative number") + return value + def _normalize_forwarded_model_id(value: str | None) -> str | None: if value is None: diff --git a/routstr/payment/models.py b/routstr/payment/models.py index dad9bbb5..6595d684 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -59,6 +59,21 @@ class Pricing(BaseModel): max_cost: float = 0.0 # in sats not msats +# 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. diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py new file mode 100644 index 00000000..376d2ca0 --- /dev/null +++ b/tests/integration/test_admin_pricing_rate_validation.py @@ -0,0 +1,211 @@ +"""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_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