mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-01 08:00:40 +00:00
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:
parent
793d1edabc
commit
0f2f199b3d
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user