mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-06 04:38:22 +00:00
test(pricing): pin rate validation to every billable rate, not the token pair
The write-edge validator and the served-catalog backstop both iterate the full billable-rate tuple, but every test drove them through `prompt` alone — the tuple could be narrowed to the two token rates and the suite stayed green, while a malformed image/search/reasoning/cache rate walked in. Covers each remaining rate at both surfaces. `request` is deliberately absent from the catalog list and pinned by its own test instead: the row-to-model conversion clamps a negative stored `request` to zero before the price is built, so the backstop never sees one. Also covers the exchange-quote reader's widened error handling, which had no test: a body that never yields JSON raises before any number is read, and unhandled it abandoned the whole aggregation with two healthy quotes in hand. 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
86be12f4be
commit
b68086c592
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user