feat(usage): add unified provider usage backend

This commit is contained in:
chengyongru
2026-08-25 01:22:25 +08:00
committed by chengyongru
parent 8bb3828487
commit 2ac802b2d5
24 changed files with 1605 additions and 808 deletions
+29
View File
@@ -1,5 +1,6 @@
"""Tests for SubagentManager."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
@@ -10,6 +11,7 @@ from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
from nanobot.llm_usage.context import llm_usage_source
from nanobot.providers.base import GenerationSettings, LLMProvider
from nanobot.security.workspace_access import build_workspace_scope
from nanobot.utils.llm_runtime import LLMRuntime
@@ -198,3 +200,30 @@ async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path):
spec = sm.runner.run.call_args.args[0]
assert spec.fail_on_tool_error is False
@pytest.mark.asyncio
async def test_spawned_subagent_inherits_llm_usage_source(tmp_path):
provider = MagicMock(spec=LLMProvider)
provider.get_default_model.return_value = "test"
sm = SubagentManager(
workspace=tmp_path,
bus=MessageBus(),
max_tool_result_chars=16_000,
)
sm.runner.run = AsyncMock(
return_value=AgentRunResult(final_content="ok", messages=[], stop_reason="completed")
)
sm._announce_result = AsyncMock()
with llm_usage_source("cron"):
await sm.spawn(
"automation task",
session_key="websocket:bound-automation",
runtime=_runtime(provider),
)
tasks = list(sm._running_tasks.values())
await asyncio.gather(*tasks)
spec = sm.runner.run.call_args.args[0]
assert spec.llm_usage_source == "cron"
+19
View File
@@ -0,0 +1,19 @@
from nanobot.llm_usage.context import source_from_request
def test_automation_metadata_overrides_user_session_source() -> None:
assert source_from_request(
"websocket:ordinary-session",
channel="websocket",
metadata={"_cron_trigger": {"job_id": "job"}},
) == "cron"
assert source_from_request(
"websocket:ordinary-session",
channel="websocket",
metadata={"_local_trigger": {"trigger_id": "trigger"}},
) == "cron"
def test_api_and_system_channels_have_explicit_sources() -> None:
assert source_from_request("shared-session", channel="api", metadata={}) == "api"
assert source_from_request("shared-session", channel="system", metadata={}) == "system"
+275
View File
@@ -0,0 +1,275 @@
"""Tests for the SQLite LLM usage store."""
from __future__ import annotations
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from nanobot.llm_usage.context import LLMUsageSource
from nanobot.llm_usage.models import LLMCallRecord
from nanobot.llm_usage.store import SCHEMA_VERSION, LLMUsageStore
from nanobot.providers.base import LLMUsage
def _timestamp(value: str) -> int:
return int(datetime.fromisoformat(value).timestamp() * 1000)
def _call(
started_at: str,
*,
provider: str = "openai",
model: str = "gpt-5",
source: LLMUsageSource = "user",
usage: LLMUsage | None = None,
finish_reason: str = "stop",
error_kind: str | None = None,
) -> LLMCallRecord:
return LLMCallRecord(
started_at_ms=_timestamp(started_at),
duration_ms=250,
provider=provider,
model=model,
source=source,
stream=True,
finish_reason=finish_reason,
usage=usage,
error_status_code=429 if finish_reason == "error" else None,
error_kind=error_kind or ("rate_limit" if finish_reason == "error" else None),
)
def test_store_keeps_only_content_free_call_metadata(tmp_path: Path) -> None:
path = tmp_path / "llm_usage.sqlite3"
store = LLMUsageStore(path)
store.record(
_call(
"2026-06-03T00:00:00+00:00",
usage=LLMUsage.reported(input_tokens=100, output_tokens=20),
)
)
row = store.recent_calls(limit=1)[0]
assert row["provider"] == "openai"
assert row["model"] == "gpt-5"
assert row["total_tokens"] == 120
assert not {
"messages",
"prompt",
"content",
"response",
"tool_calls",
"error_type",
"error_code",
} & set(row)
with sqlite3.connect(path) as connection:
version = connection.execute("PRAGMA user_version").fetchone()[0]
mode = connection.execute("PRAGMA journal_mode").fetchone()[0]
assert version == SCHEMA_VERSION
assert str(mode).lower() == "wal"
def test_usage_payload_aggregates_cache_coverage_sources_and_failures(tmp_path: Path) -> None:
store = LLMUsageStore(tmp_path / "llm_usage.sqlite3")
store.record_many(
[
_call(
"2026-06-02T23:30:00+00:00",
usage=LLMUsage.reported(
input_tokens=100,
output_tokens=20,
cache_read_tokens=40,
cache_write_tokens=10,
),
),
_call(
"2026-06-03T01:00:00+00:00",
source="api",
usage=LLMUsage.reported(input_tokens=50, output_tokens=5),
),
_call(
"2026-06-03T02:00:00+00:00",
provider="anthropic",
model="claude-sonnet-4",
source="dream",
usage=LLMUsage.estimated(input_tokens=30, output_tokens=10),
),
_call(
"2026-06-03T03:00:00+00:00",
provider="anthropic",
model="claude-sonnet-4",
source="system",
finish_reason="error",
),
]
)
payload = store.usage_payload(
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 3, 8, tzinfo=timezone.utc),
)
assert payload["total_tokens_30d"] == 215
assert payload["reported_tokens_30d"] == 175
assert payload["estimated_tokens_30d"] == 40
assert payload["requests_30d"] == 4
assert payload["failed_requests_30d"] == 1
assert payload["cache_read_tokens_30d"] == 40
assert payload["cache_read_observed_input_tokens_30d"] == 100
assert payload["cache_read_rate_30d"] == 0.4
day = payload["days"][0]
assert day["date"] == "2026-06-03"
assert day["requests"] == 4
assert day["reported_requests"] == 2
assert day["estimated_requests"] == 1
assert day["sources"]["api"]["cache_read_observed_input_tokens"] == 0
assert day["sources"]["user"]["cache_read_observed_input_tokens"] == 100
assert {(row["provider"], row["model"]) for row in payload["providers_30d"]} == {
("openai", "gpt-5"),
("anthropic", "claude-sonnet-4"),
}
def test_usage_payload_preserves_zero_cache_observation(tmp_path: Path) -> None:
store = LLMUsageStore(tmp_path / "llm_usage.sqlite3")
store.record(
_call(
"2026-06-03T00:00:00+00:00",
usage=LLMUsage.reported(
input_tokens=80,
output_tokens=5,
cache_read_tokens=0,
),
)
)
payload = store.usage_payload(
now=datetime(2026, 6, 3, 12, tzinfo=timezone.utc),
)
assert payload["cache_read_tokens_30d"] == 0
assert payload["cache_read_observed_input_tokens_30d"] == 80
assert payload["cache_read_rate_30d"] == 0.0
def test_recent_calls_is_bounded(tmp_path: Path) -> None:
store = LLMUsageStore(tmp_path / "llm_usage.sqlite3")
call = _call(
"2026-06-03T00:00:00+00:00",
usage=LLMUsage.reported(input_tokens=1, output_tokens=1),
)
store.record_many(call for _ in range(1_005))
assert len(store.recent_calls(limit=10_000)) == 1_000
def test_cancelled_calls_are_failures_and_error_kind_is_coarse(tmp_path: Path) -> None:
store = LLMUsageStore(tmp_path / "llm_usage.sqlite3")
call = _call(
"2026-06-03T00:00:00+00:00",
finish_reason="cancelled",
error_kind="provider payload: secret text",
)
store.record(call)
payload = store.usage_payload(
now=datetime(2026, 6, 3, 12, tzinfo=timezone.utc),
)
assert payload["failed_requests_30d"] == 1
assert store.recent_calls(limit=1)[0]["error_kind"] == "other"
def test_usage_payload_cache_is_isolated_and_invalidated_on_write(tmp_path: Path) -> None:
store = LLMUsageStore(tmp_path / "llm_usage.sqlite3")
first_call = _call(
"2026-06-03T00:00:00+00:00",
usage=LLMUsage.reported(input_tokens=10, output_tokens=2),
)
store.record(first_call)
kwargs = {"now": datetime(2026, 6, 3, 12, tzinfo=timezone.utc)}
first = store.usage_payload(**kwargs)
first["days"].clear()
cached = store.usage_payload(**kwargs)
assert cached["total_tokens"] == 12
assert cached["days"]
store.record(first_call)
refreshed = store.usage_payload(**kwargs)
assert refreshed["total_tokens"] == 24
def test_usage_payload_cache_is_invalidated_when_connection_pid_changes(
tmp_path: Path,
) -> None:
path = tmp_path / "llm_usage.sqlite3"
store = LLMUsageStore(path)
other_store = LLMUsageStore(path)
first_call = _call(
"2026-06-03T00:00:00+00:00",
usage=LLMUsage.reported(input_tokens=1, output_tokens=0),
)
kwargs = {"now": datetime(2026, 6, 3, 12, tzinfo=timezone.utc)}
store.record(first_call)
assert store.usage_payload(**kwargs)["total_tokens"] == 1
other_store.record(_call(
"2026-06-03T00:01:00+00:00",
usage=LLMUsage.reported(input_tokens=2, output_tokens=0),
))
store._connection_pid = -1
assert store.usage_payload(**kwargs)["total_tokens"] == 3
other_store.close()
def test_usage_query_does_not_hold_writer_lock(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LLMUsageStore(tmp_path / "llm_usage.sqlite3")
call = _call(
"2026-06-03T00:00:00+00:00",
usage=LLMUsage.reported(input_tokens=10, output_tokens=2),
)
store.record(call)
query_started = threading.Event()
release_query = threading.Event()
record_finished = threading.Event()
original_daily_rows = store._daily_rows
def slow_daily_rows(**kwargs: Any):
query_started.set()
assert release_query.wait(timeout=2)
return original_daily_rows(**kwargs)
def record_call() -> None:
store.record(call)
record_finished.set()
monkeypatch.setattr(store, "_daily_rows", slow_daily_rows)
query_thread = threading.Thread(target=lambda: store.usage_payload(
now=datetime(2026, 6, 3, 12, tzinfo=timezone.utc),
))
record_thread = threading.Thread(target=record_call)
query_thread.start()
assert query_started.wait(timeout=2)
record_thread.start()
try:
assert record_finished.wait(timeout=0.5)
finally:
release_query.set()
query_thread.join(timeout=2)
record_thread.join(timeout=2)
assert not query_thread.is_alive()
assert not record_thread.is_alive()
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from types import SimpleNamespace
import pytest
from nanobot.llm_usage.context import llm_usage_source
from nanobot.llm_usage.models import LLMCallRecord
from nanobot.providers.base import LLMProvider, LLMResponse, LLMUsage
from nanobot.providers.fallback_provider import FallbackProvider
class _SequenceProvider(LLMProvider):
_CHAT_RETRY_DELAYS = (0,)
def __init__(self, responses: Iterator[LLMResponse]) -> None:
super().__init__(provider_name="test-provider")
self._responses = responses
async def chat(self, **_kwargs: object) -> LLMResponse:
return next(self._responses)
def get_default_model(self) -> str:
return "test-model"
class _NoRetryProvider(_SequenceProvider):
_CHAT_RETRY_DELAYS = ()
class _BlockingProvider(LLMProvider):
async def chat(self, **_kwargs: object) -> LLMResponse:
await asyncio.Event().wait()
raise AssertionError("unreachable")
async def chat_stream(self, **_kwargs: object) -> LLMResponse:
await asyncio.Event().wait()
raise AssertionError("unreachable")
def get_default_model(self) -> str:
return "test-model"
@pytest.mark.asyncio
async def test_observer_receives_every_retry_attempt() -> None:
provider = _SequenceProvider(
iter(
[
LLMResponse(
content="temporary failure",
finish_reason="error",
error_kind="timeout",
),
LLMResponse(
content="ok",
usage=LLMUsage.reported(
input_tokens=100,
output_tokens=20,
cache_read_tokens=60,
),
),
]
)
)
events: list[LLMCallRecord] = []
provider.set_llm_call_observer(events.append)
with llm_usage_source("api"):
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hello"}],
model="selected-model",
)
assert response.finish_reason == "stop"
assert len(events) == 2
assert [event.finish_reason for event in events] == ["error", "stop"]
assert all(event.provider == "test-provider" for event in events)
assert all(event.model == "selected-model" for event in events)
assert all(event.source == "api" for event in events)
assert events[1].usage is not None
assert events[1].usage.cache_read_tokens == 60
@pytest.mark.asyncio
async def test_observer_estimates_missing_success_usage_without_storing_content() -> None:
provider = _SequenceProvider(iter([LLMResponse(content="hello")]))
events: list[LLMCallRecord] = []
provider.set_llm_call_observer(events.append)
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hello"}],
)
assert response.usage is not None
assert response.usage.source == "estimated"
assert events[0].usage == response.usage
assert "content" not in LLMCallRecord.__dataclass_fields__
@pytest.mark.asyncio
async def test_observer_failure_never_breaks_provider_call() -> None:
provider = _SequenceProvider(iter([LLMResponse(content="ok")]))
def _fail(_event: LLMCallRecord) -> None:
raise RuntimeError("disk unavailable")
provider.set_llm_call_observer(_fail)
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hello"}],
)
assert response.content == "ok"
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_observer_records_cancelled_provider_attempt(stream: bool) -> None:
provider = _BlockingProvider(provider_name="blocking-provider")
events: list[LLMCallRecord] = []
provider.set_llm_call_observer(events.append)
call = provider.chat_stream_with_retry if stream else provider.chat_with_retry
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(
call(messages=[{"role": "user", "content": "hello"}]),
timeout=0.01,
)
assert len(events) == 1
assert events[0].finish_reason == "cancelled"
assert events[0].error_kind == "cancelled"
assert events[0].usage is None
@pytest.mark.asyncio
async def test_fallback_provider_propagates_observer_to_every_leaf() -> None:
primary = _NoRetryProvider(
iter(
[
LLMResponse(
content="primary unavailable",
finish_reason="error",
error_kind="timeout",
)
]
)
)
fallback = _SequenceProvider(
iter(
[
LLMResponse(
content="fallback ok",
usage=LLMUsage.reported(input_tokens=12, output_tokens=3),
)
]
)
)
preset = SimpleNamespace(
model="fallback-model",
max_tokens=256,
temperature=0.2,
reasoning_effort=None,
context_window_tokens=4_096,
)
provider = FallbackProvider(primary, [preset], lambda _preset: fallback)
events: list[LLMCallRecord] = []
provider.set_llm_call_observer(events.append)
response = await provider.chat_with_retry(
messages=[{"role": "user", "content": "hello"}],
model="primary-model",
)
assert response.content == "fallback ok"
assert [(event.model, event.finish_reason) for event in events] == [
("primary-model", "error"),
("fallback-model", "stop"),
]
+23 -16
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import builtins
import json
import time
from types import SimpleNamespace
import httpx
@@ -9,6 +10,8 @@ import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
from nanobot.llm_usage import get_llm_usage_store
from nanobot.llm_usage.models import LLMCallRecord
from nanobot.providers.base import LLMUsage
from nanobot.providers.registry import find_by_name
from nanobot.session.manager import SessionManager
@@ -1463,14 +1466,16 @@ def test_settings_payload_includes_token_usage_summary(
config = Config()
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage(
LLMUsage.reported(input_tokens=10, output_tokens=5),
timezone_name=config.agents.defaults.timezone,
)
get_llm_usage_store().record(LLMCallRecord(
started_at_ms=int(time.time() * 1000),
duration_ms=1,
provider="openai",
model="gpt-5",
source="user",
stream=False,
finish_reason="stop",
usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
))
payload = settings_payload()
@@ -1491,14 +1496,16 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
config = Config()
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage(
LLMUsage.reported(input_tokens=20, output_tokens=2),
timezone_name=config.agents.defaults.timezone,
)
get_llm_usage_store().record(LLMCallRecord(
started_at_ms=int(time.time() * 1000),
duration_ms=1,
provider="openai",
model="gpt-5",
source="user",
stream=False,
finish_reason="stop",
usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
))
payload = settings_usage_payload()
+40
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import json
import threading
from collections.abc import Awaitable, Callable, Mapping
from pathlib import Path
from types import SimpleNamespace
@@ -98,6 +99,45 @@ async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> N
assert snapshot_calls == 1
@pytest.mark.asyncio
async def test_usage_query_runs_off_the_event_loop(monkeypatch) -> None:
calling_thread = threading.get_ident()
worker_threads: list[int] = []
def usage_payload(**_kwargs):
worker_threads.append(threading.get_ident())
return {"days": []}
monkeypatch.setattr("nanobot.webui.settings_routes.settings_usage_payload", usage_payload)
request = SimpleNamespace(path="/api/settings/usage", headers=Headers())
response = await _router().dispatch(None, request, request.path)
assert response is not None
assert response.status_code == 200
assert worker_threads and worker_threads[0] != calling_thread
@pytest.mark.asyncio
async def test_full_settings_query_runs_off_the_event_loop(monkeypatch) -> None:
calling_thread = threading.get_ident()
worker_threads: list[int] = []
router = _router()
def settings_response():
worker_threads.append(threading.get_ident())
return http_json_response({"ok": True})
monkeypatch.setattr(router, "_handle_settings", settings_response)
request = SimpleNamespace(path="/api/settings", headers=Headers())
response = await router.dispatch(None, request, request.path)
assert response is not None
assert response.status_code == 200
assert worker_threads and worker_threads[0] != calling_thread
@pytest.mark.asyncio
async def test_mcp_reload_callback_is_bounded(
monkeypatch: pytest.MonkeyPatch,
-319
View File
@@ -1,319 +0,0 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from nanobot.agent.hook import AgentHookContext
from nanobot.providers.base import LLMUsage
from nanobot.webui.token_usage import (
TokenUsageHook,
read_token_usage_state,
record_response_token_usage,
record_token_usage,
token_usage_payload,
write_token_usage_state,
)
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({"schema_version": 2, "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(
LLMUsage.reported(input_tokens=1, output_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")
record_token_usage(
LLMUsage.reported(
input_tokens=100,
output_tokens=40,
cache_read_tokens=20,
),
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 2, 18, 0, tzinfo=timezone.utc),
)
record_token_usage(
LLMUsage.reported(input_tokens=10, output_tokens=5),
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 2, 19, 0, tzinfo=timezone.utc),
)
payload = token_usage_payload(
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
)
assert payload["total_tokens_30d"] == 155
assert payload["active_days_30d"] == 1
assert payload["requests_30d"] == 2
assert payload["days"] == [
{
"date": "2026-06-03",
"input_tokens": 110,
"output_tokens": 45,
"cache_read_tokens": 20,
"cache_write_tokens": 0,
"cache_read_observed_input_tokens": 100,
"cache_write_observed_input_tokens": 0,
"total_tokens": 155,
"reported_tokens": 155,
"estimated_tokens": 0,
"requests": 2,
"reported_requests": 2,
"estimated_requests": 0,
"sources": {
"user": {
"input_tokens": 110,
"output_tokens": 45,
"cache_read_tokens": 20,
"cache_write_tokens": 0,
"cache_read_observed_input_tokens": 100,
"cache_write_observed_input_tokens": 0,
"total_tokens": 155,
"reported_tokens": 155,
"estimated_tokens": 0,
"requests": 2,
"reported_requests": 2,
"estimated_requests": 0,
}
},
}
]
def test_cache_observation_denominators_distinguish_missing_from_zero(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
now = datetime(2026, 6, 3, tzinfo=timezone.utc)
record_token_usage(
LLMUsage.reported(input_tokens=100, output_tokens=10),
source="user",
now=now,
)
record_token_usage(
LLMUsage.reported(
input_tokens=40,
output_tokens=5,
cache_read_tokens=0,
cache_write_tokens=0,
),
source="dream",
now=now,
)
row = token_usage_payload(now=now)["days"][0]
assert row["cache_read_tokens"] == 0
assert row["cache_write_tokens"] == 0
assert row["cache_read_observed_input_tokens"] == 40
assert row["cache_write_observed_input_tokens"] == 40
assert row["sources"]["user"]["cache_read_observed_input_tokens"] == 0
assert row["sources"]["user"]["cache_write_observed_input_tokens"] == 0
assert row["sources"]["dream"]["cache_read_observed_input_tokens"] == 40
assert row["sources"]["dream"]["cache_write_observed_input_tokens"] == 40
def _retention_state(sources: tuple[str, ...], *, day_count: int = 400) -> dict:
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
source_usage = {
"input_tokens": 100,
"output_tokens": 10,
"total_tokens": 110,
"reported_tokens": 110,
"requests": 1,
"reported_requests": 1,
}
days = {}
for offset in range(day_count):
day = (start + timedelta(days=offset)).date().isoformat()
days[day] = {
"input_tokens": 100 * len(sources),
"output_tokens": 10 * len(sources),
"total_tokens": 110 * len(sources),
"reported_tokens": 110 * len(sources),
"requests": len(sources),
"reported_requests": len(sources),
"sources": {source: dict(source_usage) for source in sources},
}
return {"schema_version": 2, "days": days}
def test_write_compact_state_keeps_400_days_with_two_sources(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
written = write_token_usage_state(_retention_state(("user", "api")))
persisted = (tmp_path / "webui" / "token-usage.json").read_bytes()
assert len(written["days"]) == 400
assert len(persisted) <= 512 * 1024
assert persisted.endswith(b"\n")
assert json.loads(persisted) == written
def test_write_prunes_only_oldest_days_to_fit_byte_budget(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
sources = ("user", "api", "cron", "dream", "system")
raw = _retention_state(sources)
all_dates = list(raw["days"])
written = write_token_usage_state(raw)
retained_dates = list(written["days"])
persisted = (tmp_path / "webui" / "token-usage.json").read_bytes()
assert 1 <= len(retained_dates) < len(all_dates)
assert retained_dates == all_dates[-len(retained_dates) :]
assert retained_dates[-1] == all_dates[-1]
assert all(set(row["sources"]) == set(sources) for row in written["days"].values())
assert len(persisted) <= 512 * 1024
assert read_token_usage_state() == written
def test_write_raises_when_latest_day_alone_exceeds_byte_budget(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._MAX_STATE_FILE_BYTES", 256)
with pytest.raises(ValueError, match="latest token usage day exceeds"):
write_token_usage_state(_retention_state(("user", "api"), day_count=1))
assert not (tmp_path / "webui" / "token-usage.json").exists()
def test_record_token_usage_skips_empty_usage(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(LLMUsage.reported(input_tokens=0, output_tokens=0))
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"] == []
assert payload["total_tokens_30d"] == 0
def test_record_token_usage_keeps_estimated_split(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
LLMUsage.estimated(input_tokens=100, output_tokens=25),
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["total_tokens"] == 125
assert payload["days"][0]["reported_tokens"] == 0
assert payload["days"][0]["estimated_tokens"] == 125
assert payload["days"][0]["estimated_requests"] == 1
def test_record_token_usage_keeps_source_breakdown(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
LLMUsage.reported(input_tokens=100, output_tokens=25, total_tokens=175),
source="user",
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
record_token_usage(
LLMUsage.reported(input_tokens=20, output_tokens=5),
source="dream",
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
row = payload["days"][0]
assert row["total_tokens"] == 200
assert row["sources"]["user"]["total_tokens"] == 175
assert row["sources"]["user"]["requests"] == 1
assert row["sources"]["dream"]["total_tokens"] == 25
assert row["sources"]["dream"]["requests"] == 1
def test_record_response_token_usage_uses_response_usage(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03")
record_response_token_usage(
SimpleNamespace(usage=LLMUsage.reported(input_tokens=20, output_tokens=5)),
source="dream",
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["sources"]["dream"]["total_tokens"] == 25
@pytest.mark.asyncio
async def test_token_usage_hook_classifies_source_from_session_key(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03")
hook = TokenUsageHook()
await hook.after_iteration(
AgentHookContext(
iteration=0,
messages=[],
session_key="cron:drink-water",
usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
)
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["sources"]["cron"]["total_tokens"] == 15