fix(admin): reject a malformed pricing rate at the write edge

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
This commit is contained in:
Jeroen Ubbink
2026-08-25 16:21:33 +02:00
co-authored by Claude Opus 5
parent 89e6acef4d
commit 754f5d1a77
3 changed files with 263 additions and 2 deletions
+37 -2
View File
@@ -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:
+15
View File
@@ -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.
@@ -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