diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index 3f4e97a20a..725f20dda9 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -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 diff --git a/backend/app/gateway/routers/console.py b/backend/app/gateway/routers/console.py index 8aedc75394..a9b2ebad55 100644 --- a/backend/app/gateway/routers/console.py +++ b/backend/app/gateway/routers/console.py @@ -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. diff --git a/backend/tests/test_console_router.py b/backend/tests/test_console_router.py index 1fafdfdba4..6780c90079 100644 --- a/backend/tests/test_console_router.py +++ b/backend/tests/test_console_router.py @@ -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())