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
+6
View File
@@ -49,6 +49,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.llm_usage.context import source_from_request
from nanobot.providers.base import LLMProvider, LLMUsage, ProviderConversationState
from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import (
@@ -1202,6 +1203,11 @@ class AgentLoop:
message_metadata=metadata,
),
provider_state=provider_state,
llm_usage_source=source_from_request(
active_session_key,
channel=channel,
metadata=metadata,
),
))
finally:
turn_scope_stack.close()
+11 -9
View File
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
from loguru import logger
from nanobot.llm_usage.context import llm_usage_source
from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import (
MIN_COMPACTED_REPLAY_MESSAGES,
@@ -915,15 +916,16 @@ class Consolidator:
if not messages:
return None
try:
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=request_messages,
tools=request_tools,
tool_choice="none",
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
with llm_usage_source("dream"):
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=request_messages,
tools=request_tools,
tool_choice="none",
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
except Exception:
logger.warning("Consolidation provider call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
+23 -10
View File
@@ -20,6 +20,12 @@ from nanobot.agent.context_governance import (
)
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.llm_usage.context import (
LLMUsageSource,
bind_llm_usage_source,
reset_llm_usage_source,
source_from_session_key,
)
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
@@ -118,6 +124,7 @@ class AgentRunSpec:
goal_continue_message: GoalContinueMessage | None = None
finalize_on_max_iterations: bool = True
provider_state: ProviderConversationState | None = None
llm_usage_source: LLMUsageSource | None = None
@dataclass(slots=True)
@@ -392,6 +399,9 @@ class AgentRunner:
hook = spec.hook or AgentHook()
messages = list(spec.initial_messages)
context = AgentRunHookContext(messages=deepcopy(messages))
llm_usage_source_token = bind_llm_usage_source(
spec.llm_usage_source or source_from_session_key(spec.session_key)
)
try:
await hook.before_run(context)
@@ -424,17 +434,20 @@ class AgentRunner:
await hook.after_run(context)
return result
finally:
context.messages = deepcopy(messages)
if context.exception is None:
await hook.on_finally(context)
else:
try:
try:
context.messages = deepcopy(messages)
if context.exception is None:
await hook.on_finally(context)
except Exception:
logger.exception(
"AgentHook.on_finally error after {}",
context.stop_reason or "run exception",
)
else:
try:
await hook.on_finally(context)
except Exception:
logger.exception(
"AgentHook.on_finally error after {}",
context.stop_reason or "run exception",
)
finally:
reset_llm_usage_source(llm_usage_source_token)
async def _run_core(
self,
+9 -1
View File
@@ -8,7 +8,7 @@ import warnings
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, TypedDict
from typing import Any, Callable, NotRequired, TypedDict
from loguru import logger
@@ -28,6 +28,7 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig
from nanobot.llm_usage.context import LLMUsageSource, current_llm_usage_source
from nanobot.providers.base import LLMProvider, LLMUsage
from nanobot.security.workspace_access import (
WorkspaceScope,
@@ -43,6 +44,7 @@ class _SubagentOrigin(TypedDict):
channel: str
chat_id: str
session_key: str | None
llm_usage_source: NotRequired[LLMUsageSource]
@dataclass(slots=True)
@@ -252,6 +254,7 @@ class SubagentManager:
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
"llm_usage_source": current_llm_usage_source(),
}
status = SubagentStatus(
@@ -315,6 +318,7 @@ class SubagentManager:
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
"llm_usage_source": current_llm_usage_source(),
}
status = SubagentStatus(
task_id=task_id,
@@ -417,6 +421,10 @@ class SubagentManager:
session_key=sess_key,
workspace=root,
llm_timeout_s=llm_timeout,
llm_usage_source=origin.get(
"llm_usage_source",
current_llm_usage_source(),
),
))
finally:
if token is not None:
+18 -24
View File
@@ -313,6 +313,8 @@ def _run_gateway(
from nanobot.cron.service import CronJobSkippedError, CronService
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob
from nanobot.llm_usage import record_llm_call
from nanobot.llm_usage.context import llm_usage_source
from nanobot.providers.factory import (
ProviderSnapshot,
build_provider_snapshot,
@@ -330,7 +332,6 @@ def _run_gateway(
)
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
webui_url = _webui_browser_url(config)
@@ -361,7 +362,8 @@ def _run_gateway(
runtime_events = RuntimeEventBus()
fallback_model_observer = build_webui_fallback_model_observer(bus)
def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
def _observe_provider(snapshot: ProviderSnapshot) -> ProviderSnapshot:
snapshot.provider.set_llm_call_observer(record_llm_call)
if isinstance(snapshot.provider, FallbackProvider):
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
return snapshot
@@ -371,20 +373,19 @@ def _run_gateway(
**kwargs: Any,
) -> ProviderSnapshot:
try:
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
return _observe_provider(load_provider_snapshot(*args, **kwargs))
except ValueError as exc:
if unconfigured_provider_error is None:
raise
return build_unconfigured_provider_snapshot(config, str(exc))
return _observe_provider(build_unconfigured_provider_snapshot(config, str(exc)))
if unconfigured_provider_error is not None:
provider_snapshot = build_unconfigured_provider_snapshot(
config,
unconfigured_provider_error,
provider_snapshot = _observe_provider(
build_unconfigured_provider_snapshot(config, unconfigured_provider_error)
)
else:
try:
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
provider_snapshot = _observe_provider(build_provider_snapshot(config))
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
@@ -443,7 +444,6 @@ def _run_gateway(
runtime_events=runtime_events,
turn_delivery_factory=turn_delivery_factory,
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
@@ -564,13 +564,6 @@ def _run_gateway(
except Exception:
logger.exception("Dream cron job failed")
finally:
from nanobot.webui.token_usage import record_response_token_usage
record_response_token_usage(
resp,
source="dream",
timezone_name=config.agents.defaults.timezone,
)
sha = _commit_dream_changes(store)
if sha:
logger.info("Dream commit: {}", sha)
@@ -630,14 +623,15 @@ def _run_gateway(
evaluator_prompt = resolve_evaluator_prompt(config.workspace_path)
# Fail closed: stay silent on evaluator failure instead of notifying.
should_notify = await evaluate_response(
response=response,
task_context=prompt,
provider=agent.provider,
model=agent.model,
evaluator_prompt=evaluator_prompt,
default_notify=False,
)
with llm_usage_source("cron"):
should_notify = await evaluate_response(
response=response,
task_context=prompt,
provider=agent.provider,
model=agent.model,
evaluator_prompt=evaluator_prompt,
default_notify=False,
)
if should_notify:
logger.info("Heartbeat: completed, delivering response")
-7
View File
@@ -479,13 +479,6 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
elapsed = time.monotonic() - t0
content = f"Dream failed after {elapsed:.1f}s: {e}"
finally:
from nanobot.webui.token_usage import record_response_token_usage
record_response_token_usage(
resp,
source="dream",
timezone_name=getattr(loop.context, "timezone", None),
)
if store.git.is_initialized():
commit_msg = build_dream_commit_message("dream: manual run", diff_body)
sha = store.git.auto_commit(commit_msg)
+86
View File
@@ -0,0 +1,86 @@
"""Unified, content-free LLM usage backend."""
from __future__ import annotations
import threading
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.config.paths import get_data_dir
from nanobot.llm_usage.models import LLMCallRecord
from nanobot.llm_usage.store import LLMUsageStore
_STORES_LOCK = threading.Lock()
_STORES: dict[Path, LLMUsageStore] = {}
def empty_usage_payload() -> dict[str, Any]:
return {
"days": [],
"total_tokens": 0,
"total_tokens_30d": 0,
"total_tokens_365d": 0,
"reported_tokens_30d": 0,
"estimated_tokens_30d": 0,
"cache_read_tokens_30d": 0,
"cache_read_observed_input_tokens_30d": 0,
"cache_read_rate_30d": None,
"peak_day_tokens": 0,
"current_streak_days": 0,
"longest_streak_days": 0,
"active_days_30d": 0,
"requests_30d": 0,
"failed_requests_30d": 0,
"providers_30d": [],
"updated_at": None,
}
def llm_usage_store_path() -> Path:
return get_data_dir() / "llm_usage.sqlite3"
def get_llm_usage_store(path: Path | None = None) -> LLMUsageStore:
resolved = (path or llm_usage_store_path()).resolve(strict=False)
with _STORES_LOCK:
store = _STORES.get(resolved)
if store is None:
store = LLMUsageStore(resolved)
_STORES[resolved] = store
return store
def record_llm_call(call: LLMCallRecord) -> None:
"""Default fail-open callback attached to gateway provider snapshots."""
try:
get_llm_usage_store().record(call)
except Exception:
logger.exception("failed to record LLM usage")
def llm_usage_payload(
*,
days: int = 371,
timezone_name: str | None = None,
) -> dict[str, Any]:
try:
return get_llm_usage_store().usage_payload(
days=days,
timezone_name=timezone_name,
)
except Exception:
logger.exception("failed to query LLM usage")
return empty_usage_payload()
__all__ = [
"LLMCallRecord",
"LLMUsageStore",
"empty_usage_payload",
"get_llm_usage_store",
"record_llm_call",
"llm_usage_store_path",
"llm_usage_payload",
]
+70
View File
@@ -0,0 +1,70 @@
"""Request-local metadata for LLM usage records."""
from __future__ import annotations
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar, Token
from typing import Literal
LLMUsageSource = Literal["user", "api", "cron", "dream", "system"]
_CURRENT_SOURCE: ContextVar[LLMUsageSource] = ContextVar(
"nanobot_llm_usage_source",
default="system",
)
def source_from_session_key(session_key: str | None) -> LLMUsageSource:
"""Classify a private session key without persisting that key."""
key = session_key or ""
if key.startswith("dream:"):
return "dream"
if key == "heartbeat" or key.startswith("cron:"):
return "cron"
if key.startswith("api:"):
return "api"
if key.startswith("system:"):
return "system"
return "user"
def source_from_request(
session_key: str | None,
*,
channel: str | None,
metadata: Mapping[str, object] | None,
) -> LLMUsageSource:
"""Classify a turn from trusted ingress metadata without retaining identifiers."""
values = metadata or {}
if isinstance(values.get("_cron_trigger"), Mapping):
return "cron"
if isinstance(values.get("_local_trigger"), Mapping):
return "cron"
if channel == "api":
return "api"
if channel == "system":
return "system"
return source_from_session_key(session_key)
def current_llm_usage_source() -> LLMUsageSource:
return _CURRENT_SOURCE.get()
def bind_llm_usage_source(source: LLMUsageSource) -> Token[LLMUsageSource]:
return _CURRENT_SOURCE.set(source)
def reset_llm_usage_source(token: Token[LLMUsageSource]) -> None:
_CURRENT_SOURCE.reset(token)
@contextmanager
def llm_usage_source(source: LLMUsageSource) -> Generator[None]:
"""Bind a coarse usage source for nested provider calls."""
token = bind_llm_usage_source(source)
try:
yield
finally:
reset_llm_usage_source(token)
+38
View File
@@ -0,0 +1,38 @@
"""Content-free records emitted for physical LLM provider calls."""
from __future__ import annotations
from dataclasses import dataclass
from nanobot.llm_usage.context import LLMUsageSource
from nanobot.providers.base import LLMUsage
@dataclass(frozen=True, slots=True)
class LLMCallRecord:
"""The small, chart-oriented result of one provider call attempt.
Request messages, response text, reasoning, and tool payloads deliberately do
not belong to this contract. Sessions already own that content.
"""
started_at_ms: int
duration_ms: int
provider: str
model: str
source: LLMUsageSource
stream: bool
finish_reason: str
usage: LLMUsage | None = None
error_status_code: int | None = None
error_kind: str | None = None
def __post_init__(self) -> None:
if self.started_at_ms < 0 or self.duration_ms < 0:
raise ValueError("LLM usage timestamps must be non-negative")
if not self.provider.strip() or not self.model.strip():
raise ValueError("LLM usage provider and model must be non-empty")
if self.source not in {"user", "api", "cron", "dream", "system"}:
raise ValueError("invalid LLM usage source")
if not self.finish_reason.strip():
raise ValueError("LLM usage finish_reason must be non-empty")
+560
View File
@@ -0,0 +1,560 @@
"""SQLite persistence and chart queries for LLM usage records."""
from __future__ import annotations
import os
import sqlite3
import threading
import time
from collections.abc import Iterable
from copy import deepcopy
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from nanobot.llm_usage.models import LLMCallRecord
SCHEMA_VERSION = 1
MAX_DAYS_RETAINED = 400
MAX_CALLS_RETAINED = 100_000
_ERROR_KINDS = frozenset({
"authentication",
"cancelled",
"configuration",
"connection",
"content_filter",
"context_length",
"empty",
"http",
"invalid_request",
"overloaded",
"permission",
"rate_limit",
"refusal",
"server_error",
"timeout",
})
_FINISH_REASONS = frozenset({
"cancelled",
"content_filter",
"error",
"function_call",
"length",
"refusal",
"stop",
"tool_calls",
})
_USAGE_COLUMNS = (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_read_observed_input_tokens",
"cache_write_observed_input_tokens",
"total_tokens",
"reported_tokens",
"estimated_tokens",
"generation_ms",
"measured_output_tokens",
"ttft_ms",
"timed_requests",
)
_REQUEST_COLUMNS = (
"requests",
"successful_requests",
"failed_requests",
"reported_requests",
"estimated_requests",
)
_AGGREGATE_SQL = """
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(SUM(
CASE WHEN cache_read_tokens IS NOT NULL THEN input_tokens ELSE 0 END
), 0) AS cache_read_observed_input_tokens,
COALESCE(SUM(
CASE WHEN cache_write_tokens IS NOT NULL THEN input_tokens ELSE 0 END
), 0) AS cache_write_observed_input_tokens,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
COALESCE(SUM(reported_tokens), 0) AS reported_tokens,
COALESCE(SUM(estimated_tokens), 0) AS estimated_tokens,
COALESCE(SUM(generation_ms), 0) AS generation_ms,
COALESCE(SUM(measured_output_tokens), 0) AS measured_output_tokens,
COALESCE(SUM(ttft_ms), 0) AS ttft_ms,
COALESCE(SUM(timed_requests), 0) AS timed_requests,
COUNT(*) AS requests,
COALESCE(SUM(CASE WHEN finish_reason IN ('error', 'cancelled') THEN 0 ELSE 1 END), 0)
AS successful_requests,
COALESCE(SUM(CASE WHEN finish_reason IN ('error', 'cancelled') THEN 1 ELSE 0 END), 0)
AS failed_requests,
COALESCE(SUM(
CASE WHEN total_tokens IS NOT NULL AND NOT (
estimated_tokens > 0 AND reported_tokens = 0
) THEN 1 ELSE 0 END
), 0) AS reported_requests,
COALESCE(SUM(
CASE WHEN estimated_tokens > 0 AND reported_tokens = 0 THEN 1 ELSE 0 END
), 0) AS estimated_requests,
COALESCE(SUM(duration_ms), 0) AS duration_ms
"""
def _zone(timezone_name: str | None) -> timezone | ZoneInfo:
if not timezone_name:
return timezone.utc
try:
return ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
return timezone.utc
def _clean_error_kind(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip().lower()
if not cleaned:
return None
return cleaned if cleaned in _ERROR_KINDS else "other"
def _clean_finish_reason(value: str) -> str:
cleaned = value.strip().lower()
return cleaned if cleaned in _FINISH_REASONS else "other"
def _clean_status_code(value: int | None) -> int | None:
if value is None:
return None
try:
status = int(value)
except (TypeError, ValueError):
return None
return status if 100 <= status <= 599 else None
def _as_int_row(row: sqlite3.Row) -> dict[str, int]:
return {
key: max(0, int(row[key] or 0))
for key in (*_USAGE_COLUMNS, *_REQUEST_COLUMNS, "duration_ms")
}
def _empty_totals() -> dict[str, int]:
return {key: 0 for key in (*_USAGE_COLUMNS, *_REQUEST_COLUMNS, "duration_ms")}
def _sum_rows(rows: Iterable[dict[str, Any]]) -> dict[str, int]:
totals = _empty_totals()
for row in rows:
for key in totals:
totals[key] += max(0, int(row.get(key) or 0))
return totals
class LLMUsageStore:
"""A small synchronous WAL database shared by gateway threads/processes."""
def __init__(self, path: Path) -> None:
self.path = path
self._lock = threading.RLock()
self._connection: sqlite3.Connection | None = None
self._connection_pid: int | None = None
self._last_prune_utc_day: int | None = None
self._writes_since_size_prune = 0
self._write_version = 0
self._cached_payload_key: tuple[int, str, str, int, int] | None = None
self._cached_payload: dict[str, Any] | None = None
def _connect(self) -> sqlite3.Connection:
pid = os.getpid()
if self._connection is not None and self._connection_pid == pid:
return self._connection
if self._connection is not None:
self._connection.close()
self._cached_payload_key = None
self._cached_payload = None
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(
self.path,
timeout=0.25,
isolation_level=None,
check_same_thread=False,
)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 250")
connection.execute("PRAGMA journal_mode = WAL")
connection.execute("PRAGMA synchronous = NORMAL")
connection.execute("PRAGMA temp_store = MEMORY")
connection.create_function("llm_usage_local_day", 2, self._local_day, deterministic=True)
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS llm_calls (
id INTEGER PRIMARY KEY,
started_at_ms INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
source TEXT NOT NULL,
stream INTEGER NOT NULL,
finish_reason TEXT NOT NULL,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reported_tokens INTEGER,
estimated_tokens INTEGER,
generation_ms INTEGER,
measured_output_tokens INTEGER,
ttft_ms INTEGER,
timed_requests INTEGER,
error_status_code INTEGER,
error_kind TEXT
);
CREATE INDEX IF NOT EXISTS llm_calls_started_at_idx
ON llm_calls(started_at_ms);
CREATE INDEX IF NOT EXISTS llm_calls_provider_model_time_idx
ON llm_calls(provider, model, started_at_ms);
"""
)
connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
self._connection = connection
self._connection_pid = pid
return connection
def _read_connection(self) -> sqlite3.Connection:
connection = sqlite3.connect(
self.path,
timeout=0.25,
isolation_level=None,
)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 250")
connection.execute("PRAGMA query_only = ON")
connection.execute("PRAGMA temp_store = MEMORY")
connection.create_function("llm_usage_local_day", 2, self._local_day, deterministic=True)
return connection
@staticmethod
def _local_day(started_at_ms: object, timezone_name: object) -> str | None:
if not isinstance(started_at_ms, int) or not isinstance(timezone_name, str):
return None
dt = datetime.fromtimestamp(started_at_ms / 1000, timezone.utc)
return dt.astimezone(_zone(timezone_name)).date().isoformat()
def close(self) -> None:
with self._lock:
if self._connection is not None:
self._connection.close()
self._connection = None
self._connection_pid = None
self._cached_payload_key = None
self._cached_payload = None
def record(self, call: LLMCallRecord) -> None:
usage = call.usage
usage_data = usage.to_dict() if usage is not None else {}
values: tuple[object, ...] = (
call.started_at_ms,
call.duration_ms,
call.provider[:120],
call.model[:240],
call.source,
int(call.stream),
_clean_finish_reason(call.finish_reason),
*(
usage_data.get(key)
for key in (
"input_tokens",
"output_tokens",
"total_tokens",
"cache_read_tokens",
"cache_write_tokens",
"reported_tokens",
"estimated_tokens",
"generation_ms",
"measured_output_tokens",
"ttft_ms",
"timed_requests",
)
),
_clean_status_code(call.error_status_code),
_clean_error_kind(call.error_kind),
)
with self._lock:
connection = self._connect()
connection.execute(
"""
INSERT INTO llm_calls (
started_at_ms, duration_ms, provider, model, source, stream,
finish_reason, input_tokens, output_tokens, total_tokens,
cache_read_tokens, cache_write_tokens, reported_tokens,
estimated_tokens, generation_ms, measured_output_tokens,
ttft_ms, timed_requests, error_status_code, error_kind
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
""",
values,
)
self._write_version += 1
self._cached_payload_key = None
self._cached_payload = None
self._prune_if_due(connection)
def _prune_if_due(self, connection: sqlite3.Connection) -> None:
utc_day = int(time.time() // 86_400)
self._writes_since_size_prune += 1
prune_age = self._last_prune_utc_day != utc_day
prune_size = self._writes_since_size_prune >= 1_024
if not prune_age and not prune_size:
return
if prune_age:
cutoff_ms = int(
(datetime.now(timezone.utc) - timedelta(days=MAX_DAYS_RETAINED)).timestamp()
* 1000
)
connection.execute("DELETE FROM llm_calls WHERE started_at_ms < ?", (cutoff_ms,))
connection.execute(
"""
DELETE FROM llm_calls
WHERE id <= COALESCE((
SELECT id FROM llm_calls ORDER BY id DESC LIMIT 1 OFFSET ?
), -1)
""",
(MAX_CALLS_RETAINED,),
)
self._last_prune_utc_day = utc_day
self._writes_since_size_prune = 0
def count(self) -> int:
with self._lock:
row = self._connect().execute("SELECT COUNT(*) AS count FROM llm_calls").fetchone()
return int(row["count"] if row is not None else 0)
def _aggregate(
self,
*,
connection: sqlite3.Connection,
start_ms: int | None,
end_ms: int,
group_by: tuple[str, ...] = (),
limit: int | None = None,
) -> list[sqlite3.Row]:
selected = f"{', '.join(group_by)}, " if group_by else ""
where = "started_at_ms < ?"
params: list[object] = [end_ms]
if start_ms is not None:
where = "started_at_ms >= ? AND started_at_ms < ?"
params = [start_ms, end_ms]
query = f"SELECT {selected}{_AGGREGATE_SQL} FROM llm_calls WHERE {where}"
if group_by:
query += f" GROUP BY {', '.join(group_by)} ORDER BY total_tokens DESC"
if limit is not None:
query += " LIMIT ?"
params.append(limit)
return list(connection.execute(query, params).fetchall())
def _daily_rows(
self,
*,
connection: sqlite3.Connection,
start_ms: int,
end_ms: int,
timezone_name: str,
) -> list[dict[str, Any]]:
query = f"""
SELECT llm_usage_local_day(started_at_ms, ?) AS date, source,
{_AGGREGATE_SQL}
FROM llm_calls
WHERE started_at_ms >= ? AND started_at_ms < ?
GROUP BY date, source
ORDER BY date, source
"""
rows = connection.execute(
query,
(timezone_name, start_ms, end_ms),
).fetchall()
by_date: dict[str, dict[str, Any]] = {}
for row in rows:
day = cast(str | None, row["date"])
if day is None:
continue
values = _as_int_row(row)
aggregate = by_date.setdefault(
day,
{"date": day, **_empty_totals(), "sources": {}},
)
for key, value in values.items():
aggregate[key] += value
aggregate["sources"][str(row["source"])] = values
return list(by_date.values())
@staticmethod
def _midnight_ms(value: date, zone: timezone | ZoneInfo) -> int:
return int(datetime.combine(value, datetime.min.time(), tzinfo=zone).timestamp() * 1000)
def usage_payload(
self,
*,
days: int = 371,
timezone_name: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
zone = _zone(timezone_name)
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
today = current.astimezone(zone).date()
safe_days = max(1, days)
zone_name = getattr(zone, "key", "UTC")
with self._lock:
data_version_row = self._connect().execute("PRAGMA data_version").fetchone()
data_version = int(data_version_row[0]) if data_version_row is not None else 0
write_version = self._write_version
cache_key = (
safe_days,
zone_name,
today.isoformat(),
write_version,
data_version,
)
if self._cached_payload_key == cache_key and self._cached_payload is not None:
return deepcopy(self._cached_payload)
connection = self._read_connection()
try:
connection.execute("BEGIN")
end_ms = self._midnight_ms(today + timedelta(days=1), zone)
retained_start = today - timedelta(days=MAX_DAYS_RETAINED - 1)
retained_start_ms = self._midnight_ms(retained_start, zone)
daily = self._daily_rows(
connection=connection,
start_ms=retained_start_ms,
end_ms=end_ms,
timezone_name=zone_name,
)
requested_start = today - timedelta(days=safe_days - 1)
visible_days = [row for row in daily if row["date"] >= requested_start.isoformat()]
last_30_start_ms = self._midnight_ms(today - timedelta(days=29), zone)
last_30_date = (today - timedelta(days=29)).isoformat()
last_365_date = (today - timedelta(days=364)).isoformat()
all_totals = _sum_rows(daily)
totals_30 = _sum_rows(row for row in daily if row["date"] >= last_30_date)
totals_365 = _sum_rows(row for row in daily if row["date"] >= last_365_date)
provider_rows = self._aggregate(
connection=connection,
start_ms=last_30_start_ms,
end_ms=end_ms,
group_by=("provider", "model"),
limit=50,
)
providers_30d = [
{
"provider": str(row["provider"]),
"model": str(row["model"]),
**_as_int_row(row),
}
for row in provider_rows
]
active_dates = {
date.fromisoformat(row["date"]) for row in daily if row["total_tokens"] > 0
}
current_streak = 0
cursor = today
while cursor in active_dates:
current_streak += 1
cursor -= timedelta(days=1)
longest_streak = 0
running_streak = 0
previous: date | None = None
for cursor in sorted(active_dates):
running_streak = running_streak + 1 if previous == cursor - timedelta(days=1) else 1
longest_streak = max(longest_streak, running_streak)
previous = cursor
latest = (
connection
.execute("SELECT MAX(started_at_ms) AS updated_at_ms FROM llm_calls")
.fetchone()
)
updated_at_ms = int(latest["updated_at_ms"] or 0) if latest is not None else 0
denominator = totals_30["cache_read_observed_input_tokens"]
payload = {
"days": visible_days,
"total_tokens": all_totals["total_tokens"],
"total_tokens_30d": totals_30["total_tokens"],
"total_tokens_365d": totals_365["total_tokens"],
"reported_tokens_30d": totals_30["reported_tokens"],
"estimated_tokens_30d": totals_30["estimated_tokens"],
"cache_read_tokens_30d": totals_30["cache_read_tokens"],
"cache_read_observed_input_tokens_30d": denominator,
"cache_read_rate_30d": (
totals_30["cache_read_tokens"] / denominator if denominator else None
),
"peak_day_tokens": max(
(int(row["total_tokens"]) for row in daily),
default=0,
),
"current_streak_days": current_streak,
"longest_streak_days": longest_streak,
"active_days_30d": sum(
1
for row in daily
if row["date"] >= last_30_date and row["total_tokens"] > 0
),
"requests_30d": totals_30["requests"],
"failed_requests_30d": totals_30["failed_requests"],
"providers_30d": providers_30d,
"updated_at": (
datetime.fromtimestamp(updated_at_ms / 1000, timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if updated_at_ms
else None
),
}
finally:
connection.close()
with self._lock:
latest_data_version_row = self._connect().execute("PRAGMA data_version").fetchone()
latest_data_version = (
int(latest_data_version_row[0])
if latest_data_version_row is not None
else 0
)
if self._write_version == write_version and latest_data_version == data_version:
self._cached_payload_key = cache_key
self._cached_payload = payload
return deepcopy(payload)
def recent_calls(self, *, limit: int = 100) -> list[dict[str, Any]]:
"""Return bounded metadata rows for diagnostics; never returns content."""
safe_limit = min(max(1, limit), 1_000)
with self._lock:
rows = (
self._connect()
.execute(
"""
SELECT * FROM llm_calls ORDER BY started_at_ms DESC, id DESC LIMIT ?
""",
(safe_limit,),
)
.fetchall()
)
return [dict(row) for row in rows]
def record_many(self, calls: Iterable[LLMCallRecord]) -> None:
for call in calls:
self.record(call)
+143 -7
View File
@@ -6,6 +6,7 @@ import asyncio
import json
import os
import re
import time
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from contextlib import suppress
@@ -13,19 +14,23 @@ from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Literal, cast
from typing import TYPE_CHECKING, Any, Literal, cast
import json_repair
from loguru import logger
from nanobot.utils.helpers import sanitize_surrogates_deep
if TYPE_CHECKING:
from nanobot.llm_usage.models import LLMCallRecord
STREAM_IDLE_TIMEOUT_ENV = "NANOBOT_STREAM_IDLE_TIMEOUT_S"
DEFAULT_STREAM_IDLE_TIMEOUT_S = 90.0
MAX_STREAM_IDLE_TIMEOUT_S = 3600.0
RETRY_AFTER_BUFFER = 1
RetryEventCallback = Callable[[str], Awaitable[None]]
LLMCallObserver = Callable[["LLMCallRecord"], None]
def resolve_stream_idle_timeout_s(
@@ -682,6 +687,95 @@ class LLMProvider(ABC):
self.api_base = api_base
self.provider_name = provider_name
self.generation: GenerationSettings = GenerationSettings()
self._llm_call_observer: LLMCallObserver | None = None
def set_llm_call_observer(self, observer: LLMCallObserver | None) -> None:
"""Attach a fail-open observer for each physical retry-managed call."""
self._llm_call_observer = observer
def _usage_for_call(
self,
response: LLMResponse,
kwargs: dict[str, Any],
) -> LLMUsage | None:
usage = response.usage
if usage is None or usage.total_tokens == 0:
if response.finish_reason in {"error", "cancelled"}:
return None
messages = kwargs.get("messages")
if not isinstance(messages, list):
return usage
tools_value = kwargs.get("tools")
tools = cast(list[dict[str, Any]], tools_value) if isinstance(tools_value, list) else None
model_value = kwargs.get("model")
model = model_value if isinstance(model_value, str) else self.get_default_model()
try:
from nanobot.utils.helpers import (
build_assistant_message,
estimate_message_tokens,
estimate_prompt_tokens_chain,
)
input_tokens, _ = estimate_prompt_tokens_chain(
self,
model,
cast(list[dict[str, Any]], messages),
tools,
)
assistant_message = build_assistant_message(
response.content or "",
tool_calls=[call.to_openai_tool_call() for call in response.tool_calls],
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
usage = LLMUsage.estimated(
input_tokens=max(0, input_tokens),
output_tokens=max(0, estimate_message_tokens(assistant_message)),
)
except Exception:
logger.exception("failed to estimate usage for {}", self.provider_name)
return usage
return usage.with_timing(
generation_ms=response.generation_ms,
ttft_ms=response.ttft_ms,
)
def _observe_llm_call(
self,
response: LLMResponse,
kwargs: dict[str, Any],
*,
started_at_ms: int,
started_at_ns: int,
stream: bool,
) -> LLMResponse:
observer = self._llm_call_observer
if observer is None:
return response
usage = self._usage_for_call(response, kwargs)
if usage is not None:
response.usage = usage
model_value = kwargs.get("model")
model = model_value if isinstance(model_value, str) and model_value else self.get_default_model()
try:
from nanobot.llm_usage.context import current_llm_usage_source
from nanobot.llm_usage.models import LLMCallRecord
observer(LLMCallRecord(
started_at_ms=started_at_ms,
duration_ms=max(0, (time.monotonic_ns() - started_at_ns) // 1_000_000),
provider=self.provider_name,
model=model,
source=current_llm_usage_source(),
stream=stream,
finish_reason=response.finish_reason,
usage=usage,
error_status_code=response.error_status_code,
error_kind=response.error_kind,
))
except Exception:
logger.exception("LLM call observer failed for {}", self.provider_name)
return response
def can_resume_conversation_state(
self,
@@ -1068,18 +1162,39 @@ class LLMProvider(ABC):
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
"""Call chat() and convert unexpected exceptions to error responses."""
started_at_ms = time.time_ns() // 1_000_000
started_at_ns = time.monotonic_ns()
try:
provider_context = kwargs.pop("provider_context", None)
if isinstance(provider_context, ProviderCallContext):
return await self.chat_with_context(
response = await self.chat_with_context(
provider_context=provider_context,
**kwargs,
)
return await self.chat(**kwargs)
else:
response = await self.chat(**kwargs)
except asyncio.CancelledError:
self._observe_llm_call(
LLMResponse(
content=None,
finish_reason="cancelled",
error_kind="cancelled",
),
kwargs,
started_at_ms=started_at_ms,
started_at_ns=started_at_ns,
stream=False,
)
raise
except Exception as exc:
return LLMResponse(content=f"Error calling LLM: {exc}", finish_reason="error")
response = LLMResponse(content=f"Error calling LLM: {exc}", finish_reason="error")
return self._observe_llm_call(
response,
kwargs,
started_at_ms=started_at_ms,
started_at_ns=started_at_ns,
stream=False,
)
async def chat_stream(
self,
@@ -1142,18 +1257,39 @@ class LLMProvider(ABC):
async def _safe_chat_stream(self, **kwargs: Any) -> LLMResponse:
"""Call chat_stream() and convert unexpected exceptions to error responses."""
started_at_ms = time.time_ns() // 1_000_000
started_at_ns = time.monotonic_ns()
try:
provider_context = kwargs.pop("provider_context", None)
if isinstance(provider_context, ProviderCallContext):
return await self.chat_stream_with_context(
response = await self.chat_stream_with_context(
provider_context=provider_context,
**kwargs,
)
return await self.chat_stream(**kwargs)
else:
response = await self.chat_stream(**kwargs)
except asyncio.CancelledError:
self._observe_llm_call(
LLMResponse(
content=None,
finish_reason="cancelled",
error_kind="cancelled",
),
kwargs,
started_at_ms=started_at_ms,
started_at_ns=started_at_ns,
stream=True,
)
raise
except Exception as exc:
return LLMResponse(content=f"Error calling LLM: {exc}", finish_reason="error")
response = LLMResponse(content=f"Error calling LLM: {exc}", finish_reason="error")
return self._observe_llm_call(
response,
kwargs,
started_at_ms=started_at_ms,
started_at_ns=started_at_ns,
stream=True,
)
async def chat_stream_with_retry(
self,
+7
View File
@@ -13,6 +13,7 @@ from loguru import logger
from nanobot.providers.base import (
GenerationSettings,
LLMCallObserver,
LLMProvider,
LLMResponse,
ProviderCallContext,
@@ -151,6 +152,11 @@ class FallbackProvider(LLMProvider):
"""Attach a process-level observer without changing request call signatures."""
self._fallback_model_observer = observer
def set_llm_call_observer(self, observer: LLMCallObserver | None) -> None:
"""Attach usage recording to the primary and future fallback leaves."""
super().set_llm_call_observer(observer)
self._primary.set_llm_call_observer(observer)
@property
def supports_progress_deltas(self) -> bool:
return bool(getattr(self._primary, "supports_progress_deltas", False))
@@ -506,6 +512,7 @@ class FallbackProvider(LLMProvider):
)
try:
fallback_provider = self._provider_factory(fallback)
fallback_provider.set_llm_call_observer(self._llm_call_observer)
except Exception as exc:
logger.warning(
"Failed to create provider for fallback '{}': {}", fallback_model, exc
+20 -18
View File
@@ -37,6 +37,7 @@ from nanobot.bus.runtime_events import (
TurnRuntimeAdmitted,
UserInputAccepted,
)
from nanobot.llm_usage.context import llm_usage_source
from nanobot.providers.base import LLMProvider, LLMUsage
from nanobot.providers.fallback_provider import FallbackModelObserver
from nanobot.runtime_context import public_history_message
@@ -208,24 +209,25 @@ async def maybe_generate_webui_title(
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=TITLE_GENERATION_MAX_TOKENS,
temperature=0.2,
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
retry_mode="standard",
)
with llm_usage_source("system"):
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=TITLE_GENERATION_MAX_TOKENS,
temperature=0.2,
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
+2 -2
View File
@@ -284,9 +284,9 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
if route == ("root", "settings"):
return self._handle_settings()
return await asyncio.to_thread(self._handle_settings)
if route == ("root", "usage"):
return self._handle_settings_usage()
return await asyncio.to_thread(self._handle_settings_usage)
domain, action = route
domain_request = self._domain_request(
+3 -3
View File
@@ -20,6 +20,7 @@ from nanobot.channels.contracts import (
channel_update_instance_config,
)
from nanobot.config.schema import Config
from nanobot.llm_usage import llm_usage_payload
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.settings_capabilities import network_safety_payload
@@ -31,7 +32,6 @@ from nanobot.webui.settings_contracts import (
query_first,
query_first_alias,
)
from nanobot.webui.token_usage import token_usage_payload
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsServices
@@ -121,7 +121,7 @@ def system_settings_payload(
},
"unified_session": defaults.unified_session,
},
"usage": token_usage_payload(timezone_name=defaults.timezone),
"usage": llm_usage_payload(timezone_name=defaults.timezone),
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"workspace_sandbox": sandbox_status.as_dict(),
@@ -139,7 +139,7 @@ def system_settings_payload(
def settings_usage_payload(config: Config) -> dict[str, Any]:
"""Return the lightweight token usage slice for Overview refreshes."""
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
return llm_usage_payload(timezone_name=config.agents.defaults.timezone)
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
-392
View File
@@ -1,392 +0,0 @@
"""Workspace-scoped token usage telemetry for WebUI overview surfaces."""
from __future__ import annotations
import json
import os
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Mapping, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.config.paths import get_webui_dir
from nanobot.providers.base import LLMUsage
TOKEN_USAGE_SCHEMA_VERSION = 2
_MAX_STATE_FILE_BYTES = 512 * 1024
_MAX_DAYS_RETAINED = 400
_USAGE_KEYS = (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_read_observed_input_tokens",
"cache_write_observed_input_tokens",
"total_tokens",
"reported_tokens",
"estimated_tokens",
)
_REQUEST_KEYS = ("requests", "reported_requests", "estimated_requests")
_SOURCE_KEYS = ("user", "api", "cron", "dream", "system")
_WRITE_LOCK = threading.Lock()
def token_usage_state_path() -> Path:
return get_webui_dir() / "token-usage.json"
def default_token_usage_state() -> dict[str, Any]:
return {
"schema_version": TOKEN_USAGE_SCHEMA_VERSION,
"days": {},
"updated_at": None,
}
def _utc_now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _zone(timezone_name: str | None) -> timezone | ZoneInfo:
if not timezone_name:
return timezone.utc
try:
return ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
return timezone.utc
def _local_day(now: datetime | None = None, *, timezone_name: str | None = None) -> str:
dt = now or datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_zone(timezone_name)).date().isoformat()
def _clean_int(value: Any) -> int:
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _clean_source(value: str | None) -> str:
return value if value in _SOURCE_KEYS else "system"
def _source_from_session_key(session_key: str | None) -> str:
key = session_key or ""
if key.startswith("dream:"):
return "dream"
if key == "heartbeat" or key.startswith("cron:"):
return "cron"
if key.startswith("api:"):
return "api"
if key.startswith("system:"):
return "system"
return "user"
def _normalize_usage(raw: LLMUsage | None) -> dict[str, int]:
if raw is None:
return {}
usage = {
"input_tokens": raw.input_tokens,
"output_tokens": raw.output_tokens,
"cache_read_tokens": raw.cache_read_tokens or 0,
"cache_write_tokens": raw.cache_write_tokens or 0,
"cache_read_observed_input_tokens": (
raw.input_tokens if raw.cache_read_tokens is not None else 0
),
"cache_write_observed_input_tokens": (
raw.input_tokens if raw.cache_write_tokens is not None else 0
),
"total_tokens": raw.total_tokens,
"reported_tokens": raw.reported_tokens,
"estimated_tokens": raw.estimated_tokens,
}
return usage if usage["total_tokens"] > 0 else {}
def _normalize_usage_row(row: dict[str, Any]) -> dict[str, int]:
cleaned = {key: _clean_int(row.get(key)) for key in _USAGE_KEYS}
if cleaned["total_tokens"] <= 0:
cleaned["total_tokens"] = cleaned["input_tokens"] + cleaned["output_tokens"]
if cleaned["reported_tokens"] <= 0 and cleaned["estimated_tokens"] <= 0:
cleaned["reported_tokens"] = cleaned["total_tokens"]
requests = {key: _clean_int(row.get(key)) for key in _REQUEST_KEYS}
if (
requests["requests"] > 0
and requests["reported_requests"] <= 0
and requests["estimated_requests"] <= 0
):
if cleaned["estimated_tokens"] > 0 and cleaned["reported_tokens"] <= 0:
requests["estimated_requests"] = requests["requests"]
else:
requests["reported_requests"] = requests["requests"]
return {**cleaned, **requests}
def _normalize_sources(raw: Any, fallback: dict[str, int]) -> dict[str, dict[str, int]]:
sources: dict[str, dict[str, int]] = {}
if isinstance(raw, dict):
for source, row_value in cast(dict[Any, Any], raw).items():
if not isinstance(row_value, dict):
continue
row = cast(dict[str, Any], row_value)
normalized = _normalize_usage_row(row)
if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0:
continue
source_key = _clean_source(str(source))
current = sources.get(source_key)
if current is None:
sources[source_key] = normalized
else:
for key in (*_USAGE_KEYS, *_REQUEST_KEYS):
current[key] = _clean_int(current.get(key)) + normalized[key]
if not sources and (fallback["total_tokens"] > 0 or fallback["requests"] > 0):
sources["user"] = {key: fallback[key] for key in (*_USAGE_KEYS, *_REQUEST_KEYS)}
return sources
def normalize_token_usage_state(raw: Any) -> dict[str, Any]:
state = default_token_usage_state()
if not isinstance(raw, dict):
return state
raw = cast(dict[str, Any], raw)
if raw.get("schema_version") != TOKEN_USAGE_SCHEMA_VERSION:
return state
days_raw = raw.get("days")
if not isinstance(days_raw, dict):
return state
days: dict[str, dict[str, Any]] = {}
for date, row_value in sorted(cast(dict[Any, Any], days_raw).items())[-_MAX_DAYS_RETAINED:]:
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
days[date] = {
"date": date,
**normalized,
"sources": _normalize_sources(row.get("sources"), normalized),
}
state["days"] = days
updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
return state
def read_token_usage_state() -> dict[str, Any]:
path = token_usage_state_path()
if not path.is_file():
return default_token_usage_state()
try:
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
logger.warning("token usage state too large, ignoring: {}", path)
return default_token_usage_state()
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("read token usage state failed {}: {}", path, e)
return default_token_usage_state()
return normalize_token_usage_state(raw)
def _encode_token_usage_state(state: dict[str, Any]) -> bytes:
"""Encode the persisted state compactly, including its trailing newline."""
payload = json.dumps(
state,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
return f"{payload}\n".encode("utf-8")
def write_token_usage_state(raw: dict[str, Any]) -> dict[str, Any]:
# Day-count retention is applied by normalization first. The byte budget
# then trims only the oldest remaining days, preserving a contiguous suffix.
state = normalize_token_usage_state(raw)
state["updated_at"] = _utc_now_iso()
days = cast(dict[str, dict[str, Any]], state["days"])
encoded = _encode_token_usage_state(state)
while len(encoded) > _MAX_STATE_FILE_BYTES and len(days) > 1:
del days[min(days)]
encoded = _encode_token_usage_state(state)
if len(encoded) > _MAX_STATE_FILE_BYTES:
raise ValueError("latest token usage day exceeds the state byte limit")
path = token_usage_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "wb") as f:
f.write(encoded)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
dir_fd = os.open(path.parent, os.O_RDONLY)
except OSError:
return state
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
return state
def record_token_usage(
usage: LLMUsage | None,
*,
source: str = "user",
timezone_name: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
normalized = _normalize_usage(usage)
if not normalized:
return read_token_usage_state()
with _WRITE_LOCK:
state = read_token_usage_state()
days_by_date = cast(dict[str, dict[str, Any]], state["days"])
day = _local_day(now, timezone_name=timezone_name)
row: dict[str, Any] = dict(days_by_date.get(day) or {"date": day, "requests": 0})
for key in _USAGE_KEYS:
row[key] = _clean_int(row.get(key)) + normalized.get(key, 0)
row["requests"] = _clean_int(row.get("requests")) + 1
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("reported_tokens", 0) <= 0:
row["estimated_requests"] = _clean_int(row.get("estimated_requests")) + 1
else:
row["reported_requests"] = _clean_int(row.get("reported_requests")) + 1
source_key = _clean_source(source)
sources: dict[str, dict[str, Any]] = dict(
cast(Mapping[str, dict[str, Any]], row.get("sources") or {})
)
source_row: dict[str, Any] = dict(sources.get(source_key) or {"requests": 0})
for key in _USAGE_KEYS:
source_row[key] = _clean_int(source_row.get(key)) + normalized.get(key, 0)
source_row["requests"] = _clean_int(source_row.get("requests")) + 1
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("reported_tokens", 0) <= 0:
source_row["estimated_requests"] = _clean_int(source_row.get("estimated_requests")) + 1
else:
source_row["reported_requests"] = _clean_int(source_row.get("reported_requests")) + 1
sources[source_key] = source_row
row["sources"] = sources
days_by_date[day] = row
if len(days_by_date) > _MAX_DAYS_RETAINED:
state["days"] = dict(sorted(days_by_date.items())[-_MAX_DAYS_RETAINED:])
return write_token_usage_state(state)
def record_response_token_usage(
response: Any,
*,
source: str,
timezone_name: str | None = None,
) -> None:
try:
record_token_usage(
getattr(response, "usage", None),
source=source,
timezone_name=timezone_name,
)
except Exception:
logger.exception("failed to record {} token usage", source)
def token_usage_payload(
*,
days: int = 371,
timezone_name: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
state = read_token_usage_state()
days_by_date = cast(dict[str, dict[str, Any]], state["days"])
today = datetime.fromisoformat(_local_day(now, timezone_name=timezone_name)).date()
start = today - timedelta(days=max(1, days) - 1)
day_rows = [
row
for date, row in sorted(days_by_date.items())
if start.isoformat() <= date <= today.isoformat()
]
last_30_start = today - timedelta(days=29)
last_30 = [
row
for date, row in days_by_date.items()
if last_30_start.isoformat() <= date <= today.isoformat()
]
last_365_start = today - timedelta(days=364)
last_365 = [
row
for date, row in days_by_date.items()
if last_365_start.isoformat() <= date <= today.isoformat()
]
active_dates = {
datetime.fromisoformat(date).date()
for date, row in days_by_date.items()
if _clean_int(row.get("total_tokens")) > 0
}
current_streak = 0
cursor = today
while cursor in active_dates:
current_streak += 1
cursor -= timedelta(days=1)
longest_streak = 0
running_streak = 0
for cursor in sorted(active_dates):
if cursor - timedelta(days=1) in active_dates:
running_streak += 1
else:
running_streak = 1
longest_streak = max(longest_streak, running_streak)
all_rows = list(days_by_date.values())
return {
"days": day_rows,
"total_tokens": sum(_clean_int(row.get("total_tokens")) for row in all_rows),
"total_tokens_30d": sum(_clean_int(row.get("total_tokens")) for row in last_30),
"total_tokens_365d": sum(_clean_int(row.get("total_tokens")) for row in last_365),
"peak_day_tokens": max([_clean_int(row.get("total_tokens")) for row in all_rows] or [0]),
"current_streak_days": current_streak,
"longest_streak_days": longest_streak,
"active_days_30d": sum(1 for row in last_30 if _clean_int(row.get("total_tokens")) > 0),
"requests_30d": sum(_clean_int(row.get("requests")) for row in last_30),
"updated_at": state.get("updated_at"),
}
class TokenUsageHook(AgentHook):
"""Persist provider-reported token usage without coupling it to chat messages."""
def __init__(self, *, timezone_name: str | None = None) -> None:
super().__init__()
self._timezone_name = timezone_name
async def after_iteration(self, context: AgentHookContext) -> None:
try:
record_token_usage(
context.usage,
source=_source_from_session_key(context.session_key),
timezone_name=self._timezone_name,
)
except Exception:
logger.exception("failed to record token usage")