diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py index 78614a8b..5b53b02a 100644 --- a/tests/integration/test_admin_pricing_rate_validation.py +++ b/tests/integration/test_admin_pricing_rate_validation.py @@ -308,3 +308,85 @@ async def test_admin_model_listing_shows_a_non_finite_stored_rate( 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" diff --git a/tests/integration/test_served_catalog_rate_backstop.py b/tests/integration/test_served_catalog_rate_backstop.py index 02829bae..7819c017 100644 --- a/tests/integration/test_served_catalog_rate_backstop.py +++ b/tests/integration/test_served_catalog_rate_backstop.py @@ -187,3 +187,71 @@ async def test_one_unreadable_stored_price_does_not_blank_the_catalog( 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 diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py index 9e4a5d6e..d3656d25 100644 --- a/tests/unit/test_pricing_rate_validation.py +++ b/tests/unit/test_pricing_rate_validation.py @@ -189,6 +189,12 @@ class _ExchangeResponse: 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 @@ -200,9 +206,10 @@ class _ExchangeClient: async def get(self, url: str) -> _ExchangeResponse: if "kraken" in url: - return _ExchangeResponse( - {"result": {"XXBTZUSD": {"c": [self._quotes["kraken"]]}}} - ) + 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"]}) @@ -297,6 +304,31 @@ async def test_boolean_exchange_quote_does_not_set_the_node_price( 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 precisely so that one of them + having a bad day is survivable. A body that is not JSON, or whose shape moved + under the reader, raises before any number is seen; unhandled, it aborted the + whole aggregation and left the node on a stale rate even though two healthy + quotes were already in hand. + """ + 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,