From 07c2677eed6ae00af9cedf74623536f54a514b15 Mon Sep 17 00:00:00 2001 From: KDB <937925477@qq.com> Date: Tue, 28 Jul 2026 13:30:57 +0800 Subject: [PATCH] fix(webui): drop malformed token-usage day keys normalize_token_usage_state only length-checked persisted day keys, so a hand-edited or foreign 10-char key (e.g. "not-a-dat3" or "2026-13-01") in token-usage.json survived reads and atomic rewrites. token_usage_payload then parsed every day key with an unguarded datetime.fromisoformat, so one such key failed every /api/settings and /api/settings/usage request until the file was repaired by hand. Validate day keys in normalize_token_usage_state, the shared boundary that every read, record, and rewrite already funnels through. Malformed keys are dropped like other malformed rows and scrubbed from the file on the next write; valid state is unchanged. --- nanobot/webui/token_usage.py | 7 +++++ tests/webui/test_token_usage.py | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/nanobot/webui/token_usage.py b/nanobot/webui/token_usage.py index 1e72b5e69..f06e9421f 100644 --- a/nanobot/webui/token_usage.py +++ b/nanobot/webui/token_usage.py @@ -159,6 +159,13 @@ def normalize_token_usage_state(raw: Any) -> dict[str, Any]: if not isinstance(date, str) or len(date) != 10 or not isinstance(row_value, dict): continue row = cast(dict[str, Any], row_value) + try: + datetime.fromisoformat(date) + except ValueError: + # A hand-edited or foreign day key that is not a real date would + # otherwise reach token_usage_payload's date parsing and fail every + # settings request; drop it like any other malformed row. + continue normalized = _normalize_usage_row(row) if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0: continue diff --git a/tests/webui/test_token_usage.py b/tests/webui/test_token_usage.py index 470c10230..6514909fa 100644 --- a/tests/webui/test_token_usage.py +++ b/tests/webui/test_token_usage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timezone from types import SimpleNamespace @@ -14,6 +15,60 @@ from nanobot.webui.token_usage import ( ) +def _write_state(tmp_path, days: dict) -> None: + state_dir = tmp_path / "webui" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "token-usage.json").write_text( + json.dumps({"days": days}), encoding="utf-8" + ) + + +def test_payload_tolerates_malformed_persisted_day_keys(tmp_path, monkeypatch) -> None: + """Day keys that are not real dates must not break settings payloads. + + normalize_token_usage_state only length-checks day keys, so a hand-edited + 10-char key survives reads and atomic rewrites; token_usage_payload then + parsed it with an unguarded fromisoformat, failing every /api/settings and + /api/settings/usage request until the file was fixed by hand. + """ + monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui") + _write_state(tmp_path, { + "not-a-dat3": {"total_tokens": 7, "requests": 1}, + "2026-13-01": {"total_tokens": 9, "requests": 1}, + "2026-06-02": {"total_tokens": 5, "requests": 1}, + }) + + payload = token_usage_payload( + timezone_name="UTC", + now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc), + ) + + assert payload["total_tokens"] == 5 + assert payload["total_tokens_30d"] == 5 + assert payload["requests_30d"] == 1 + assert payload["active_days_30d"] == 1 + + +def test_record_scrubs_malformed_day_keys(tmp_path, monkeypatch) -> None: + """Rewrites drop malformed day keys instead of persisting them forever.""" + monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui") + _write_state(tmp_path, { + "not-a-dat3": {"total_tokens": 7, "requests": 1}, + "2026-06-02": {"total_tokens": 5, "requests": 1}, + }) + + record_token_usage( + {"prompt_tokens": 1, "completion_tokens": 1}, + timezone_name="UTC", + now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc), + ) + + raw = json.loads((tmp_path / "webui" / "token-usage.json").read_text(encoding="utf-8")) + assert "not-a-dat3" not in raw["days"] + assert "2026-06-02" in raw["days"] + assert "2026-06-03" in raw["days"] + + def test_record_token_usage_aggregates_by_local_day(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")