mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
feat(usage): add unified provider usage backend
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user