llm-connect/llm_connect/usage.py
tegwick 09aa1f3604
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled
Implement LLM-WP-0007: Kimi K3 default and EUR spend reporting
Add moonshotai/kimi-k3 as OpenRouter basemodel default after live smoke,
USD→EUR cost conversion, append-only usage ledger, and CLI run/cost/spend
week commands with token and euro reporting.
2026-08-03 22:00:34 +02:00

473 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Append-only usage ledger for operator spend reporting.
Separate from :class:`~llm_connect.quality.QualityLedger`, which stores adaptive
routing quality signals. This ledger is for tokens + estimated cost accounting.
"""
from __future__ import annotations
import contextvars
import json
import os
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator, TextIO
from zoneinfo import ZoneInfo
from llm_connect.costs import CostEstimate, estimate_cost
from llm_connect.fx import FxRate
from llm_connect.models import LLMResponse
from llm_connect.rates import ModelRateRegistry
ENV_USAGE_LEDGER = "LLM_CONNECT_USAGE_LEDGER"
ENV_TZ = "LLM_CONNECT_TZ"
DEFAULT_TZ = "Europe/Berlin"
_PATH_LOCKS: dict[Path, threading.Lock] = {}
_PATH_LOCKS_GUARD = threading.Lock()
_AUTO_RECORD_DISABLED: contextvars.ContextVar[bool] = contextvars.ContextVar(
"llm_connect_auto_record_disabled",
default=False,
)
@contextmanager
def suppress_auto_usage_record() -> Iterator[None]:
"""Disable env-based auto recording (e.g. while CLI records explicitly)."""
token = _AUTO_RECORD_DISABLED.set(True)
try:
yield
finally:
_AUTO_RECORD_DISABLED.reset(token)
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _normalise_datetime(value: datetime | str) -> datetime:
if isinstance(value, datetime):
dt = value
elif isinstance(value, str):
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
raise TypeError(f"Expected datetime or ISO string, got {type(value).__name__}")
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _serialise_datetime(value: datetime) -> str:
return _normalise_datetime(value).isoformat().replace("+00:00", "Z")
def _validate_non_negative_int(name: str, value: int) -> None:
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
def _path_lock(path: Path) -> threading.Lock:
resolved = path.resolve()
with _PATH_LOCKS_GUARD:
lock = _PATH_LOCKS.get(resolved)
if lock is None:
lock = threading.Lock()
_PATH_LOCKS[resolved] = lock
return lock
def _lock_file(handle: TextIO) -> None:
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
def _unlock_file(handle: TextIO) -> None:
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@contextmanager
def _locked_file(path: Path, mode: str) -> Iterator[TextIO]:
path.parent.mkdir(parents=True, exist_ok=True)
local_lock = _path_lock(path)
with local_lock:
with path.open(mode, encoding="utf-8") as handle:
_lock_file(handle)
try:
yield handle
finally:
_unlock_file(handle)
def default_usage_ledger_path(*, env: dict[str, str] | None = None) -> Path:
"""Resolve the default usage ledger path.
Order: ``LLM_CONNECT_USAGE_LEDGER`` → ``$XDG_DATA_HOME/llm-connect/usage.jsonl``
→ ``~/.local/share/llm-connect/usage.jsonl``.
"""
environ = env if env is not None else os.environ
explicit = environ.get(ENV_USAGE_LEDGER)
if explicit and str(explicit).strip():
return Path(str(explicit).strip()).expanduser()
xdg = environ.get("XDG_DATA_HOME")
if xdg and str(xdg).strip():
return Path(str(xdg).strip()).expanduser() / "llm-connect" / "usage.jsonl"
return Path.home() / ".local" / "share" / "llm-connect" / "usage.jsonl"
@dataclass(frozen=True)
class UsageEvent:
"""One recorded LLM call for spend accounting."""
provider: str
model_id: str
prompt_tokens: int
completion_tokens: int
total_tokens: int
cost_usd: float | None
cost_eur: float | None
cost_source: str
source: str
fx_source: str | None = None
recorded_at: datetime = field(default_factory=_utc_now)
tags: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("provider", "model_id", "source", "cost_source"):
if not str(getattr(self, name)).strip():
raise ValueError(f"{name} must be a non-empty string")
for name in ("prompt_tokens", "completion_tokens", "total_tokens"):
_validate_non_negative_int(name, getattr(self, name))
if self.cost_usd is not None and (
not isinstance(self.cost_usd, (int, float)) or float(self.cost_usd) < 0
):
raise ValueError("cost_usd must be a non-negative number or None")
if self.cost_eur is not None and (
not isinstance(self.cost_eur, (int, float)) or float(self.cost_eur) < 0
):
raise ValueError("cost_eur must be a non-negative number or None")
object.__setattr__(self, "provider", str(self.provider).strip())
object.__setattr__(self, "model_id", str(self.model_id).strip())
object.__setattr__(self, "source", str(self.source).strip())
object.__setattr__(self, "cost_source", str(self.cost_source).strip())
object.__setattr__(
self,
"cost_usd",
None if self.cost_usd is None else float(self.cost_usd),
)
object.__setattr__(
self,
"cost_eur",
None if self.cost_eur is None else float(self.cost_eur),
)
object.__setattr__(self, "recorded_at", _normalise_datetime(self.recorded_at))
object.__setattr__(self, "tags", dict(self.tags or {}))
if self.fx_source is not None:
object.__setattr__(self, "fx_source", str(self.fx_source))
def to_dict(self) -> dict[str, Any]:
"""Convert to a JSON-serialisable dictionary."""
return {
"provider": self.provider,
"model_id": self.model_id,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"total_tokens": self.total_tokens,
"cost_usd": self.cost_usd,
"cost_eur": self.cost_eur,
"cost_source": self.cost_source,
"fx_source": self.fx_source,
"source": self.source,
"recorded_at": _serialise_datetime(self.recorded_at),
"tags": dict(self.tags),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "UsageEvent":
"""Create an event from a JSON-decoded dictionary."""
return cls(
provider=data["provider"],
model_id=data["model_id"],
prompt_tokens=int(data["prompt_tokens"]),
completion_tokens=int(data["completion_tokens"]),
total_tokens=int(data["total_tokens"]),
cost_usd=data.get("cost_usd"),
cost_eur=data.get("cost_eur"),
cost_source=data.get("cost_source") or "unknown",
fx_source=data.get("fx_source"),
source=data.get("source") or "library",
recorded_at=data.get("recorded_at", _utc_now()),
tags=data.get("tags") or {},
)
@dataclass(frozen=True)
class UsageSummary:
"""Aggregated tokens and cost over a time window."""
start: datetime
end: datetime
event_count: int
prompt_tokens: int
completion_tokens: int
total_tokens: int
cost_usd: float | None
cost_eur: float | None
unknown_cost_events: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"start": _serialise_datetime(self.start),
"end": _serialise_datetime(self.end),
"event_count": self.event_count,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"total_tokens": self.total_tokens,
"cost_usd": self.cost_usd,
"cost_eur": self.cost_eur,
"unknown_cost_events": self.unknown_cost_events,
}
class UsageLedger:
"""Append-only JSONL store for :class:`UsageEvent` records."""
def __init__(self, path: str | Path):
self._path = Path(path)
@property
def path(self) -> Path:
"""Ledger file path."""
return self._path
def append(self, event: UsageEvent) -> None:
"""Append one event as a locked JSONL record."""
line = json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":"))
with _locked_file(self._path, "a") as handle:
handle.write(line + "\n")
handle.flush()
os.fsync(handle.fileno())
def read_all(self) -> list[UsageEvent]:
"""Return all parseable events, skipping malformed lines."""
events, _ = self._read_with_malformed_count()
return events
def iter_events(self) -> Iterator[UsageEvent]:
"""Yield parseable events in file order."""
yield from self.read_all()
def sum_range(
self,
start: datetime,
end: datetime,
) -> UsageSummary:
"""Aggregate events in the half-open interval ``[start, end)``."""
start_utc = _normalise_datetime(start)
end_utc = _normalise_datetime(end)
if end_utc < start_utc:
raise ValueError("end must be >= start")
prompt_tokens = 0
completion_tokens = 0
total_tokens = 0
cost_usd_total = 0.0
cost_eur_total = 0.0
has_usd = False
has_eur = False
unknown_cost = 0
count = 0
for event in self.read_all():
if event.recorded_at < start_utc or event.recorded_at >= end_utc:
continue
count += 1
prompt_tokens += event.prompt_tokens
completion_tokens += event.completion_tokens
total_tokens += event.total_tokens
if event.cost_usd is None:
unknown_cost += 1
else:
has_usd = True
cost_usd_total += event.cost_usd
if event.cost_eur is not None:
has_eur = True
cost_eur_total += event.cost_eur
return UsageSummary(
start=start_utc,
end=end_utc,
event_count=count,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost_usd=cost_usd_total if has_usd else (0.0 if count == 0 else None),
cost_eur=cost_eur_total if has_eur else (0.0 if count == 0 else None),
unknown_cost_events=unknown_cost,
)
def _read_with_malformed_count(self) -> tuple[list[UsageEvent], int]:
if not self._path.is_file():
return [], 0
events: list[UsageEvent] = []
malformed = 0
with _locked_file(self._path, "r") as handle:
for line in handle:
if not line.strip():
continue
try:
events.append(UsageEvent.from_dict(json.loads(line)))
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
malformed += 1
return events, malformed
def week_window(
which: str = "current",
*,
now: datetime | None = None,
tz: str | None = None,
) -> tuple[datetime, datetime]:
"""Return ``(start, end)`` for a Monday-based spend window.
* ``current`` — Monday 00:00 local → *now*
* ``last`` — previous Monday 00:00 → this Monday 00:00 (full MonSun)
Endpoints are timezone-aware in the requested local zone (default
Europe/Berlin). Comparisons in the ledger normalise to UTC.
"""
which_normalised = str(which).strip().lower()
if which_normalised not in {"current", "last"}:
raise ValueError("which must be 'current' or 'last'")
zone_name = (tz or os.environ.get(ENV_TZ) or DEFAULT_TZ).strip() or DEFAULT_TZ
zone = ZoneInfo(zone_name)
reference = now or datetime.now(zone)
if reference.tzinfo is None:
reference = reference.replace(tzinfo=zone)
else:
reference = reference.astimezone(zone)
start_of_this_week = reference.replace(
hour=0, minute=0, second=0, microsecond=0
) - timedelta(days=reference.weekday())
if which_normalised == "current":
return start_of_this_week, reference
end = start_of_this_week
start = end - timedelta(days=7)
return start, end
def event_from_response(
response: LLMResponse,
*,
provider: str,
source: str,
registry: ModelRateRegistry | None = None,
fx: FxRate | float | None = None,
tags: dict[str, Any] | None = None,
recorded_at: datetime | None = None,
) -> UsageEvent:
"""Build a :class:`UsageEvent` from an :class:`LLMResponse` and cost model."""
usage = response.usage or {}
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
model_id = response.model or "unknown"
estimate = estimate_cost(
model_id,
prompt_tokens,
completion_tokens,
registry=registry,
fx=fx,
)
return UsageEvent(
provider=provider,
model_id=model_id,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost_usd=estimate.cost_usd,
cost_eur=estimate.cost_eur,
cost_source=estimate.cost_source,
fx_source=estimate.fx_source,
source=source,
recorded_at=recorded_at or _utc_now(),
tags=tags or {},
)
def maybe_record_usage(
response: LLMResponse,
*,
provider: str,
source: str,
tags: dict[str, Any] | None = None,
env: dict[str, str] | None = None,
registry: ModelRateRegistry | None = None,
fx: FxRate | float | None = None,
) -> UsageEvent | None:
"""Record usage when ``LLM_CONNECT_USAGE_LEDGER`` is set; otherwise no-op.
Returns the recorded event, or ``None`` when recording is disabled.
"""
if _AUTO_RECORD_DISABLED.get():
return None
environ = env if env is not None else os.environ
path_value = environ.get(ENV_USAGE_LEDGER)
if not path_value or not str(path_value).strip():
return None
event = event_from_response(
response,
provider=provider,
source=source,
registry=registry,
fx=fx,
tags=tags,
)
UsageLedger(Path(str(path_value).strip()).expanduser()).append(event)
return event
def format_cost_footer(
estimate: CostEstimate,
*,
prompt_tokens: int,
completion_tokens: int,
total_tokens: int,
model_id: str,
) -> str:
"""Human-readable one-line cost summary for CLI footers."""
tokens_part = (
f"tokens: in={prompt_tokens} out={completion_tokens} total={total_tokens}"
)
if estimate.cost_usd is None:
usd_part = "usd=unknown"
else:
usd_part = f"usd={estimate.cost_usd:.6f}"
if estimate.cost_eur is None:
eur_part = "eur=unknown"
else:
eur_part = f"eur={estimate.cost_eur:.6f}"
return f"cost[{model_id}]: {tokens_part} · {eur_part} · {usd_part} · source={estimate.cost_source}"