fix(console): tolerate malformed usage counters and pricing (#7736)

* fix(console): tolerate malformed usage counters and pricing

* docs(routers): record console counter tolerance in the router contract

* fix(console): reject stored counters beyond the float range

A JSON integer is not bounded by the float range: 10**400 parses as an
exact int, so _token_count passed it through and _token_cost then raised
OverflowError on the division back to float, taking /usage, /stats and
/runs down with a 500 - the same failure mode the rest of this PR
removes for every other junk shape.

Reject counts whose float conversion is not finite inside the existing
try, so they coerce to 0 and the run falls back to its legacy totals.
10**30 stays usable.

---------

Co-authored-by: gouyu-hou <gouyu-hou@users.noreply.github.com>
Co-authored-by: gouyu-hou <213296172+gouyu-hou@users.noreply.github.com>
This commit is contained in:
gouyu-hou 2026-10-31 18:05:16 +08:00 committed by GitHub
parent 793d1edabc
commit 0f2f199b3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 128 additions and 9 deletions

View File

@ -18,7 +18,10 @@ leave the source record unchanged.
Console usage attributes legacy rows without a per-model usage map to
`model_name`, including their recorded `total_input_tokens`. Accumulate those
inputs with newer usage buckets for the same model; do not infer cache hits.
Regression coverage lives in `tests/test_console_router.py`.
A counter that does not coerce to an integer contributes zero tokens instead
of aborting the request, and non-finite pricing leaves that model's cost
unreported; rows whose counters are all unusable still fall back to run-level
totals. Regression coverage lives in `tests/test_console_router.py`.
Shared scheduled-task update validation preserves an unchanged future `once`
instant on a live task inside `scheduler.min_once_delay_seconds`; new/changed

View File

@ -13,6 +13,7 @@ which persists no run history to report on.
import asyncio
import logging
import math
from datetime import UTC, datetime, time, timedelta
from typing import NamedTuple
@ -181,6 +182,14 @@ def _build_pricing_map() -> dict[str, _ModelPricing]:
except (TypeError, ValueError):
logger.warning("console: ignoring malformed pricing on model %s", model_cfg.name)
continue
# ``float("nan")`` / ``float(".inf")`` parse successfully, and NaN then
# survives every ``<=`` comparison below and every ``round()``, ending up
# in the JSON body where Starlette rejects it (allow_nan=False) -- a 500
# for the whole usage report. Treat non-finite prices as malformed, the
# same way the rest of the config layer does (allow_inf_nan=False).
if any(not math.isfinite(price) for price in (input_price, output_price, cache_hit_price) if price is not None):
logger.warning("console: ignoring non-finite pricing on model %s", model_cfg.name)
continue
if input_price <= 0 and output_price <= 0:
continue
model_currency = str(raw.get("currency") or "USD").strip().upper() or "USD"
@ -228,8 +237,36 @@ def _token_cost(input_tokens: int, output_tokens: int, price: _ModelPricing, cac
return (uncached / 1_000_000) * price.input_per_million + (cache_read / 1_000_000) * hit_price + (output_tokens / 1_000_000) * price.output_per_million
def _token_count(value: object) -> int:
"""Coerce one stored token counter to a non-negative ``int``.
``token_usage_by_model`` is a JSON column written from provider-reported
usage, so a bucket value is not guaranteed to be a number: providers have
shipped counters as strings ("n/a", "1.0"), and rows written by older
build/import paths may hold anything JSON can express. The console already
skips buckets that are not dicts and pricing that is not numeric; coercing
counters the same way keeps one junk bucket from failing the whole report
with a 500. Booleans are rejected because ``int(True) == 1`` would invent a
token, and negatives cannot be spend.
A JSON integer is not bounded by the float range: ``10**400`` parses as an
exact ``int``, and ``_token_cost`` then divides it by a million, which
raises ``OverflowError`` on the way back to a float. Such a value is junk
in exactly the same way as ``"n/a"``, so it is rejected here too.
"""
if isinstance(value, bool):
return 0
try:
count = int(value) # type: ignore[arg-type]
if count < 0 or not math.isfinite(float(count)):
return 0
except (TypeError, ValueError, OverflowError):
return 0
return count
def _usage_is_missing(usage: dict) -> bool:
return int(usage.get("missing_usage_calls") or 0) > 0
return _token_count(usage.get("missing_usage_calls")) > 0
def _has_missing_usage(token_usage_by_model: object) -> bool:
@ -263,11 +300,11 @@ def _run_cost(
price = _lookup_pricing(pricing, model)
if price is None:
continue
input_tokens = int(usage.get("input_tokens") or 0)
output_tokens = int(usage.get("output_tokens") or 0)
input_tokens = _token_count(usage.get("input_tokens"))
output_tokens = _token_count(usage.get("output_tokens"))
if input_tokens == 0 and output_tokens == 0:
continue
cost += _token_cost(input_tokens, output_tokens, price, int(usage.get("cache_read_tokens") or 0))
cost += _token_cost(input_tokens, output_tokens, price, _token_count(usage.get("cache_read_tokens")))
priced = True
if priced:
return cost
@ -511,9 +548,9 @@ async def console_usage(
entry.runs += 1
if not isinstance(usage, dict):
continue
entry.tokens += int(usage.get("total_tokens", 0) or 0)
entry.input_tokens += int(usage.get("input_tokens") or 0)
entry.cache_read_tokens += int(usage.get("cache_read_tokens") or 0)
entry.tokens += _token_count(usage.get("total_tokens"))
entry.input_tokens += _token_count(usage.get("input_tokens"))
entry.cache_read_tokens += _token_count(usage.get("cache_read_tokens"))
price = _lookup_pricing(pricing, model)
if _usage_is_missing(usage):
missing_models.add(model.lower())
@ -522,7 +559,7 @@ async def console_usage(
# equal rates on an unrelated model are not aliases.
missing_model_prices.add(id(price))
if price is not None:
model_cost = _token_cost(int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0), price, int(usage.get("cache_read_tokens") or 0))
model_cost = _token_cost(_token_count(usage.get("input_tokens")), _token_count(usage.get("output_tokens")), price, _token_count(usage.get("cache_read_tokens")))
entry.cost = round((entry.cost or 0.0) + model_cost, 6)
elif row.model_name and run_tokens > 0:
# Legacy rows predating token_usage_by_model: fall back to the run's model.

View File

@ -529,3 +529,82 @@ class TestNoSqlBackend:
resp = c.get(path)
assert resp.status_code == 503
assert "SQL database backend" in resp.json()["detail"]
class TestMalformedUsageTolerance:
"""A JSON column written from provider data must not break the console.
``runs.token_usage_by_model`` is a schemaless JSON column, and the router
already skips buckets that are not dicts and pricing that is not numeric.
Counter values were the one layer left unguarded: ``int("n/a")`` raised out
of the request and turned every console endpoint into a 500.
"""
@pytest.mark.parametrize("value", ["n/a", "1.5", None, float("nan"), float("inf"), True, False, -7, [1], {"n": 1}, 10**400])
def test_token_count_falls_back_to_zero_for_unusable_values(self, value):
assert console._token_count(value) == 0
@pytest.mark.parametrize("value, expected", [(0, 0), (12, 12), ("13", 13), (2.0, 2), (10**30, 10**30)])
def test_token_count_keeps_usable_values(self, value, expected):
assert console._token_count(value) == expected
def test_usage_report_survives_malformed_token_counters(self, client, session_factory):
_insert_usage_row(session_factory, "r-junk", {"minimax-m2": {"input_tokens": "n/a", "output_tokens": None, "total_tokens": "1.5", "cache_read_tokens": [1], "missing_usage_calls": "bogus"}})
resp = client.get("/api/console/usage")
assert resp.status_code == 200
# The bucket still exists (the run happened) but contributes no tokens.
assert resp.json()["by_model"]["minimax-m2"]["tokens"] == 1200 + 300
def test_run_cost_ignores_malformed_counters_but_keeps_legacy_fallback(self, session_factory, monkeypatch, client):
_insert_usage_row(session_factory, "r-junk", {"minimax-m2": {"input_tokens": "n/a", "output_tokens": "n/a"}})
monkeypatch.setattr(console, "get_app_config", lambda: _priced_config())
runs = client.get("/api/console/runs", params={"limit": 50}).json()
# Both counters coerce to 0 -> the bucket is skipped, and the run keeps
# its legacy run-level totals priced against model_name.
row = next(r for r in runs["runs"] if r["run_id"] == "r-junk")
assert row["cost"] == pytest.approx(_R1_COST_UNCACHED)
def test_run_cost_survives_counters_beyond_float_range(self, session_factory, monkeypatch, client):
# A JSON integer is not bounded by the float range: 10**400 parses as
# an exact int, and dividing it by a million raised OverflowError out
# of /runs. It is junk like "n/a", so it must not reach _token_cost.
_insert_usage_row(session_factory, "r-huge", {"minimax-m2": {"input_tokens": 10**400, "output_tokens": 0}})
monkeypatch.setattr(console, "get_app_config", lambda: _priced_config())
resp = client.get("/api/console/runs", params={"limit": 50})
assert resp.status_code == 200
row = next(r for r in resp.json()["runs"] if r["run_id"] == "r-huge")
assert row["cost"] == pytest.approx(_R1_COST_UNCACHED)
def test_non_finite_pricing_is_ignored_instead_of_500(self, session_factory, monkeypatch, client):
monkeypatch.setattr(
console,
"get_app_config",
lambda: SimpleNamespace(models=[SimpleNamespace(name="minimax-m2", model="MiniMax-M2", pricing={"input_per_million": float("nan"), "output_per_million": 32})]),
)
resp = client.get("/api/console/usage")
assert resp.status_code == 200
# No usable pricing -> costs stay unreported rather than NaN in the body.
assert resp.json()["by_model"]["minimax-m2"]["cost"] is None
def _insert_usage_row(session_factory, run_id: str, usage_map: dict) -> None:
async def insert():
async with session_factory() as session:
session.add(
RunRow(
run_id=run_id,
thread_id="t1",
user_id="user-a",
status="success",
model_name="minimax-m2",
total_tokens=1200,
total_input_tokens=800,
total_output_tokens=400,
token_usage_by_model=usage_map,
created_at=NOW - timedelta(minutes=5),
updated_at=NOW - timedelta(minutes=4),
)
)
await session.commit()
asyncio.run(insert())