mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
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:
co-authored by
Claude Opus 5
parent
89e6acef4d
commit
754f5d1a77
+37
-2
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user