mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: store numeric-string model prices as floats so overrides serve instead of 500
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user