From b005162365798e292aa33371f13c944cdce8c83b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 3 Oct 2026 00:37:20 +0200 Subject: [PATCH] fix: store numeric-string model prices as floats so overrides serve instead of 500 --- routstr/core/admin.py | 12 ++- routstr/payment/models.py | 4 +- .../test_admin_pricing_rate_validation.py | 92 +++++++++++++++++-- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 365e2f68..46406381 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -492,9 +492,11 @@ class ModelCreate(BaseModel): 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. + persisting it. Numeric strings (``"0.000005"``) stay valid and are + stored as the number they spell, so the read path never meets a string + rate; an omitted auxiliary rate is valid too. """ + normalized = dict(value) for field in BILLABLE_PRICING_FIELDS: if field not in value: # ``dict.get`` cannot tell this from an explicit ``null``, so @@ -507,12 +509,14 @@ class ModelCreate(BaseModel): # 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: + rate = coerce_rate(value[field]) + if rate is None: raise ValueError( f"{field} must be a finite, non-negative number, " f"got {value[field]!r}" ) - return value + normalized[field] = rate + return normalized def _normalize_forwarded_model_id(value: str | None) -> str | None: diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 8d7788a1..3f925e19 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -346,8 +346,10 @@ def _build_model_from_row( ) top_provider_dict = json.loads(row.top_provider) if row.top_provider else None + # Rows written before the admin edge normalized rates can still carry + # numeric strings (``"0"``); compare as floats so the clamp cannot raise. if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0: - pricing["request"] = max(pricing.get("request", 0.0), 0.0) + pricing["request"] = 0.0 parsed_pricing = Pricing.parse_obj(pricing) diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py index fb6cfa18..db48d40d 100644 --- a/tests/integration/test_admin_pricing_rate_validation.py +++ b/tests/integration/test_admin_pricing_rate_validation.py @@ -233,9 +233,9 @@ async def test_an_absent_auxiliary_rate_is_still_accepted( 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.""" + """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 — it is stored as the number it spells.""" provider_id = await _make_provider(integration_session) resp = await integration_client.post( @@ -249,7 +249,87 @@ async def test_numeric_string_price_is_still_accepted( 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" + assert json.loads(row.pricing)["prompt"] == pytest.approx(5e-06) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_all_string_prices_are_accepted_and_served( + integration_client: AsyncClient, integration_session: AsyncSession +) -> None: + """Every rate spelled as a string must be stored as a number and served. + + The UI round-trips rates through text fields, so a payload can carry all of + them as strings. The read path compared the stored ``request`` string + against a float (``max("0", 0.0)``), so the write answered a 500 after the + row was committed, and the served catalog then skipped the row as + unreadable — the model never appeared in ``/v1/models``. + """ + provider_id = await _make_provider(integration_session) + pricing: dict[str, object] = {k: str(v) for k, v in _pricing().items()} + + resp = await integration_client.post( + f"/admin/api/upstream-providers/{provider_id}/models", + headers=_admin_headers(), + json=_payload(provider_id, model_id="all-strings", pricing=pricing), + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["pricing"]["prompt"] == pytest.approx(1.4e-7) + row = await integration_session.get(ModelRow, ("all-strings", provider_id)) + assert row is not None + stored = json.loads(row.pricing) + assert stored["prompt"] == pytest.approx(1.4e-7) + assert stored["request"] == 0.0 + assert all(isinstance(v, float) for v in stored.values()) + + public = await integration_client.get("/v1/models") + assert public.status_code == 200 + served = {m["id"]: m for m in public.json()["data"]} + assert "all-strings" in served + assert served["all-strings"]["pricing"]["prompt"] == pytest.approx(1.4e-7) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stored_string_prices_still_serve( + integration_client: AsyncClient, integration_session: AsyncSession +) -> None: + """A row written before the edge normalized strings must still be readable.""" + provider_id = await _make_provider(integration_session) + integration_session.add( + ModelRow( + id="legacy-strings", + name="legacy-strings", + 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": "0.000001", "completion": "0.000002", "request": "0"} + ), + upstream_provider_id=provider_id, + enabled=True, + forwarded_model_id="legacy-strings", + ) + ) + await integration_session.commit() + await reinitialize_upstreams() + + public = await integration_client.get("/v1/models") + assert public.status_code == 200 + served = {m["id"]: m for m in public.json()["data"]} + assert "legacy-strings" in served + assert served["legacy-strings"]["pricing"]["prompt"] == pytest.approx(1e-06) + assert served["legacy-strings"]["pricing"]["request"] == 0.0 def test_non_finite_price_is_rejected_by_the_write_model() -> None: @@ -415,9 +495,7 @@ async def test_malformed_auxiliary_rate_is_rejected( # test is that the SERVER answers the bare NaN/Infinity literals # with a 422, so the literals must still reach it. body = json.dumps( - _payload( - provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad}) - ) + _payload(provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad})) ).encode("utf-8") resp = await integration_client.post( f"/admin/api/upstream-providers/{provider_id}/models",