Merge pull request #799 from Routstr/fix/model-override-string-prices

fix: store numeric-string model prices as floats so overrides serve instead of 500
This commit is contained in:
9qeklajc
2026-10-03 02:08:01 +02:00
committed by GitHub
3 changed files with 96 additions and 12 deletions
+8 -4
View File
@@ -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:
+3 -1
View File
@@ -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",