Implement LLM-WP-0007: Kimi K3 default and EUR spend reporting
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled

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.
This commit is contained in:
tegwick 2026-08-03 22:00:34 +02:00
parent f3121c3f1f
commit 09aa1f3604
21 changed files with 1396 additions and 103 deletions

View file

@ -8,7 +8,7 @@ Quick start::
from llm_connect import create_adapter
adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4")
adapter = create_adapter("openrouter", model="moonshotai/kimi-k3")
response = adapter.execute_prompt(prompt, run_config)
"""
@ -16,6 +16,7 @@ from llm_connect.adapter import ErrorLLMAdapter, LLMAdapter, MockLLMAdapter
from llm_connect.claude_code import ClaudeCodeAdapter
from llm_connect.config import LLMConfig, load_config
from llm_connect.costs import CostEstimate, CostModel, estimate_cost
from llm_connect.fx import FxRate, resolve_fx_rate
from llm_connect.embedding_adapter import EmbeddingAdapter
from llm_connect.embedding_cache import EmbeddingCache
from llm_connect.embedding_factory import create_embedding_adapter
@ -71,6 +72,15 @@ from llm_connect.similarity import (
find_similar_pairs,
similarity_matrix,
)
from llm_connect.usage import (
UsageEvent,
UsageLedger,
UsageSummary,
default_usage_ledger_path,
event_from_response,
maybe_record_usage,
week_window,
)
__all__ = [
"RunConfig",
@ -120,6 +130,15 @@ __all__ = [
"CostEstimate",
"CostModel",
"estimate_cost",
"FxRate",
"resolve_fx_rate",
"UsageEvent",
"UsageLedger",
"UsageSummary",
"default_usage_ledger_path",
"event_from_response",
"maybe_record_usage",
"week_window",
"TokenEstimate",
"Observation",
"ProblemClass",

View file

@ -93,6 +93,26 @@ class LLMAdapter(ABC):
if config.budget_tracker is not None:
tokens = response.usage.get("total_tokens", 0)
config.budget_tracker.consume(tokens)
self._maybe_record_usage(response)
def _maybe_record_usage(self, response: LLMResponse) -> None:
"""Opt-in usage ledger write when ``LLM_CONNECT_USAGE_LEDGER`` is set."""
try:
from llm_connect.usage import maybe_record_usage
provider = (
(response.metadata or {}).get("provider")
or getattr(self, "provider_id", None)
or type(self).__name__
)
maybe_record_usage(
response,
provider=str(provider),
source="library",
)
except Exception:
# Spend accounting must never break inference.
return
class MockLLMAdapter(LLMAdapter):

View file

@ -1,16 +1,30 @@
"""Command-line helpers for llm-connect registries."""
"""Command-line helpers for llm-connect registries, run, and spend."""
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Iterable, Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from llm_connect.costs import estimate_cost
from llm_connect.factory import create_adapter
from llm_connect.fx import resolve_fx_rate
from llm_connect.models import RunConfig
from llm_connect.problem_classes import ProblemClass, ProblemClassRegistry
from llm_connect.quality import QualityLedger
from llm_connect.rates import ModelRateRegistry
from llm_connect.usage import (
UsageLedger,
default_usage_ledger_path,
event_from_response,
format_cost_footer,
suppress_auto_usage_record,
week_window,
)
def main(argv: list[str] | None = None) -> int:
@ -43,6 +57,55 @@ def _build_parser() -> argparse.ArgumentParser:
class_fit.add_argument("--min-observations", type=int, default=3)
class_fit.add_argument("--json", action="store_true", help="Emit JSON")
class_fit.set_defaults(func=_classes_fit)
run = commands.add_parser("run", help="Execute a prompt and report tokens + cost")
run.add_argument("prompt", help="Prompt text to send")
run.add_argument("--provider", default="openrouter", help="Provider key (default openrouter)")
run.add_argument("--model", default=None, help="Model id (provider default if omitted)")
run.add_argument("--temperature", type=float, default=0.7)
run.add_argument("--max-tokens", type=int, default=2000)
run.add_argument("--timeout", type=int, default=300, help="Timeout seconds")
run.add_argument("--ledger", type=Path, default=None, help="Usage ledger JSONL path")
run.add_argument("--no-ledger", action="store_true", help="Do not append to the usage ledger")
run.add_argument("--json", action="store_true", help="Emit JSON response + cost")
run.add_argument(
"--eur-per-usd",
type=float,
default=None,
help="Override FX rate (euros per one USD)",
)
run.set_defaults(func=_run_prompt)
cost = commands.add_parser("cost", help="Cost estimation helpers")
cost_commands = cost.add_subparsers(dest="cost_command", required=True)
cost_estimate = cost_commands.add_parser(
"estimate",
help="Estimate cost for token counts without calling a provider",
)
cost_estimate.add_argument("--model", required=True, help="Model id")
cost_estimate.add_argument("--prompt-tokens", type=int, required=True)
cost_estimate.add_argument("--completion-tokens", type=int, default=0)
cost_estimate.add_argument("--rates", type=Path, help="YAML registry overlay")
cost_estimate.add_argument("--eur-per-usd", type=float, default=None)
cost_estimate.add_argument("--json", action="store_true", help="Emit JSON")
cost_estimate.set_defaults(func=_cost_estimate)
spend = commands.add_parser("spend", help="Inspect recorded spend")
spend_commands = spend.add_subparsers(dest="spend_command", required=True)
spend_week = spend_commands.add_parser(
"week",
help="Weekly spend: current Mon→now, or --last for previous MonSun",
)
spend_week.add_argument(
"--last",
action="store_true",
help="Previous calendar week (Mon 00:00 → Sun end / this Mon 00:00)",
)
spend_week.add_argument("--ledger", type=Path, default=None, help="Usage ledger JSONL path")
spend_week.add_argument("--tz", default=None, help="Timezone (default Europe/Berlin)")
spend_week.add_argument("--json", action="store_true", help="Emit JSON")
spend_week.set_defaults(func=_spend_week)
return parser
@ -123,6 +186,160 @@ def _classes_fit(args: argparse.Namespace) -> int:
return 0
def _run_prompt(args: argparse.Namespace) -> int:
fx = resolve_fx_rate(args.eur_per_usd)
adapter_kwargs: dict[str, Any] = {}
if args.model is not None:
adapter_kwargs["model"] = args.model
adapter = create_adapter(args.provider, **adapter_kwargs)
config = RunConfig(
model_name=args.model or "gpt-4",
temperature=args.temperature,
max_tokens=args.max_tokens,
timeout_seconds=args.timeout,
)
with suppress_auto_usage_record():
response = adapter.execute_prompt(args.prompt, config)
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 args.model or "unknown"
estimate = estimate_cost(
model_id,
prompt_tokens,
completion_tokens,
fx=fx,
)
if not args.no_ledger:
ledger_path = args.ledger or default_usage_ledger_path()
event = event_from_response(
response,
provider=args.provider,
source="cli",
fx=fx,
)
UsageLedger(ledger_path).append(event)
footer = format_cost_footer(
estimate,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
model_id=model_id,
)
if args.json:
print(
json.dumps(
{
"content": response.content,
"model": model_id,
"usage": {
"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,
"finish_reason": response.finish_reason,
},
indent=2,
sort_keys=True,
)
)
return 0
print(response.content)
print(footer, file=sys.stderr)
return 0
def _cost_estimate(args: argparse.Namespace) -> int:
if args.prompt_tokens < 0 or args.completion_tokens < 0:
raise SystemExit("token counts must be non-negative")
registry = ModelRateRegistry.default()
if args.rates:
registry = registry.merged_with(ModelRateRegistry.from_yaml(args.rates))
estimate = estimate_cost(
args.model,
args.prompt_tokens,
args.completion_tokens,
registry=registry,
fx=args.eur_per_usd,
)
total = args.prompt_tokens + args.completion_tokens
if args.json:
print(
json.dumps(
{
"model": args.model,
"prompt_tokens": args.prompt_tokens,
"completion_tokens": args.completion_tokens,
"total_tokens": total,
"cost_usd": estimate.cost_usd,
"cost_eur": estimate.cost_eur,
"cost_source": estimate.cost_source,
"fx_source": estimate.fx_source,
},
indent=2,
sort_keys=True,
)
)
return 0
print(
format_cost_footer(
estimate,
prompt_tokens=args.prompt_tokens,
completion_tokens=args.completion_tokens,
total_tokens=total,
model_id=args.model,
)
)
return 0
def _spend_week(args: argparse.Namespace) -> int:
which = "last" if args.last else "current"
start, end = week_window(which, tz=args.tz)
ledger_path = args.ledger or default_usage_ledger_path()
summary = UsageLedger(ledger_path).sum_range(start, end)
if args.json:
payload = summary.to_dict()
payload["which"] = which
payload["tz"] = args.tz or "Europe/Berlin"
payload["ledger"] = str(ledger_path)
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
label = "last week (MonSun)" if which == "last" else "current week (Monnow)"
print(f"spend {label}")
print(f"window: {_fmt_local(start)}{_fmt_local(end)}")
print(f"ledger: {ledger_path}")
print(f"events: {summary.event_count}")
print(
f"tokens: in={summary.prompt_tokens} out={summary.completion_tokens} "
f"total={summary.total_tokens}"
)
usd = "unknown" if summary.cost_usd is None else f"{summary.cost_usd:.6f}"
eur = "unknown" if summary.cost_eur is None else f"{summary.cost_eur:.6f}"
print(f"cost: eur={eur} usd={usd}")
if summary.unknown_cost_events:
print(f"unknown_cost_events: {summary.unknown_cost_events}")
return 0
def _fmt_local(dt: datetime) -> str:
return dt.isoformat()
def _classes_payload(classes: Iterable[ProblemClass]) -> dict[str, dict[str, Any]]:
return {
problem_class.name: {

View file

@ -24,7 +24,7 @@ class LLMConfig:
"""
provider: str = "openrouter"
model: str = "anthropic/claude-sonnet-4"
model: str = "moonshotai/kimi-k3"
api_key: Optional[str] = None
api_base: str = "https://openrouter.ai/api/v1"
claude_cli_path: str = "claude"
@ -101,7 +101,7 @@ def load_config(
defaults: Dict[str, Any] = {
"provider": provider,
"model": model or "anthropic/claude-sonnet-4",
"model": model or "moonshotai/kimi-k3",
"api_key": resolved_key,
}
defaults.update(overrides)

View file

@ -5,17 +5,27 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from llm_connect.fx import FxRate, resolve_fx_rate
from llm_connect.rates import ModelRateRegistry
@dataclass(frozen=True)
class CostEstimate:
"""Cost estimate split by prompt and completion token spend."""
"""Cost estimate split by prompt and completion token spend.
USD fields come from the rate table. EUR fields are derived via :class:`FxRate`
when conversion is available; missing FX yields ``cost_eur=None`` with an
explicit ``fx_source`` rather than silently treating spend as zero.
"""
cost_usd: float | None
cost_source: str
prompt_cost_usd: float | None = None
completion_cost_usd: float | None = None
cost_eur: float | None = None
prompt_cost_eur: float | None = None
completion_cost_eur: float | None = None
fx_source: str | None = None
def estimate_cost(
@ -24,11 +34,16 @@ def estimate_cost(
completion_tokens: int = 0,
*,
registry: ModelRateRegistry | None = None,
fx: FxRate | float | None = None,
apply_fx: bool = True,
) -> CostEstimate:
"""Estimate USD cost for token counts using *registry*.
"""Estimate USD (and optionally EUR) cost for token counts using *registry*.
Unknown models return ``CostEstimate(None, "unknown")`` so callers can
record uncertainty explicitly instead of treating missing prices as zero.
When *apply_fx* is true (default), EUR fields are filled using *fx* or the
resolved default FX rate. Pass ``apply_fx=False`` to skip conversion.
"""
prompt_count = _non_negative_int("prompt_tokens", prompt_tokens)
completion_count = _non_negative_int("completion_tokens", completion_tokens)
@ -39,11 +54,31 @@ def estimate_cost(
prompt_cost = (prompt_count / 1000.0) * rate.prompt_per_1k
completion_cost = (completion_count / 1000.0) * rate.completion_per_1k
cost_usd = prompt_cost + completion_cost
cost_eur = None
prompt_cost_eur = None
completion_cost_eur = None
fx_source: str | None = None
if apply_fx:
resolved = resolve_fx_rate(fx)
if resolved is None:
fx_source = "unknown"
else:
fx_source = resolved.source
cost_eur = resolved.usd_to_eur(cost_usd)
prompt_cost_eur = resolved.usd_to_eur(prompt_cost)
completion_cost_eur = resolved.usd_to_eur(completion_cost)
return CostEstimate(
cost_usd=prompt_cost + completion_cost,
cost_usd=cost_usd,
cost_source=f"rate_table:{rate.model_id}",
prompt_cost_usd=prompt_cost,
completion_cost_usd=completion_cost,
cost_eur=cost_eur,
prompt_cost_eur=prompt_cost_eur,
completion_cost_eur=completion_cost_eur,
fx_source=fx_source,
)
@ -52,6 +87,8 @@ class CostModel:
"""Small wrapper for callers that prefer an object over a free function."""
registry: ModelRateRegistry | None = None
fx: FxRate | float | None = None
apply_fx: bool = True
def estimate_cost(
self,
@ -59,12 +96,14 @@ class CostModel:
prompt_tokens: int,
completion_tokens: int = 0,
) -> CostEstimate:
"""Estimate cost using this model's registry."""
"""Estimate cost using this model's registry and FX settings."""
return estimate_cost(
model_id,
prompt_tokens,
completion_tokens,
registry=self.registry,
fx=self.fx,
apply_fx=self.apply_fx,
)

93
llm_connect/fx.py Normal file
View file

@ -0,0 +1,93 @@
"""USD to EUR conversion helpers for display and spend reporting.
Rate tables remain USD-denominated (OpenRouter list prices). This module only
converts already-estimated USD amounts into euros.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
# Snapshot: euros per one US dollar. Operator can override via env.
DEFAULT_EUR_PER_USD = 0.92
DEFAULT_FX_CAPTURED_AT = "2026-08-03"
DEFAULT_FX_SOURCE = f"snapshot:{DEFAULT_FX_CAPTURED_AT}"
ENV_EUR_PER_USD = "LLM_CONNECT_EUR_PER_USD"
@dataclass(frozen=True)
class FxRate:
"""EUR conversion factor for one USD."""
eur_per_usd: float
source: str
captured_at: str = ""
def __post_init__(self) -> None:
rate = _positive_float("eur_per_usd", self.eur_per_usd)
source = str(self.source or "").strip()
if not source:
raise ValueError("source must be a non-empty string")
object.__setattr__(self, "eur_per_usd", rate)
object.__setattr__(self, "source", source)
object.__setattr__(self, "captured_at", str(self.captured_at or ""))
def usd_to_eur(self, amount_usd: float) -> float:
"""Convert a USD amount to EUR."""
return float(amount_usd) * self.eur_per_usd
def resolve_fx_rate(
explicit: FxRate | float | None = None,
*,
env: dict[str, str] | None = None,
) -> FxRate | None:
"""Resolve an FX rate from explicit value, environment, or bundled snapshot.
Resolution order:
1. *explicit* :class:`FxRate` or positive float (euros per USD)
2. Environment variable ``LLM_CONNECT_EUR_PER_USD``
3. Bundled snapshot rate
Returns ``None`` only when an explicit/env value is present but invalid
callers should pass nothing to get the snapshot. Empty env string falls
through to the snapshot.
"""
if explicit is not None:
if isinstance(explicit, FxRate):
return explicit
return FxRate(
eur_per_usd=float(explicit),
source="explicit",
captured_at="",
)
environ = env if env is not None else os.environ
raw = environ.get(ENV_EUR_PER_USD)
if raw is not None and str(raw).strip() != "":
return FxRate(
eur_per_usd=_positive_float(ENV_EUR_PER_USD, raw),
source=f"env:{ENV_EUR_PER_USD}",
captured_at="",
)
return FxRate(
eur_per_usd=DEFAULT_EUR_PER_USD,
source=DEFAULT_FX_SOURCE,
captured_at=DEFAULT_FX_CAPTURED_AT,
)
def _positive_float(name: str, value: Any) -> float:
if isinstance(value, bool):
raise ValueError(f"{name} must be a positive number")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be a positive number") from exc
if number <= 0:
raise ValueError(f"{name} must be a positive number")
return number

View file

@ -12,7 +12,7 @@ from llm_connect.config import LLMConfig, find_project_root, resolve_api_key
from llm_connect.exceptions import LLMAPIError, LLMRateLimitError
from llm_connect.models import LLMResponse, RunConfig
_DEFAULT_MODEL = "anthropic/claude-sonnet-4"
_DEFAULT_MODEL = "moonshotai/kimi-k3"
class OpenRouterAdapter(LLMAdapter):

View file

@ -91,7 +91,8 @@ class ModelRateRegistry:
return ModelRateRegistry(merged)
_DEFAULT_RATES: dict[str, tuple[float, float]] = {
# prompt_per_1k, completion_per_1k, optional captured_at override
_DEFAULT_RATES: dict[str, tuple[float, float] | tuple[float, float, str]] = {
"openai/gpt-4o-mini": (0.00015, 0.00060),
"openai/gpt-4o": (0.0025, 0.01),
"openai/gpt-4-turbo": (0.01, 0.03),
@ -101,21 +102,28 @@ _DEFAULT_RATES: dict[str, tuple[float, float]] = {
"google/gemini-1.5-flash": (0.000075, 0.0003),
"google/gemini-1.5-pro": (0.00125, 0.005),
"meta-llama/llama-3.1-70b-instruct": (0.00059, 0.00079),
# OpenRouter list price 2026-08-03: $0.000003/prompt token, $0.000015/completion
"moonshotai/kimi-k3": (0.003, 0.015, "2026-08-03"),
}
def _default_rate_payload() -> dict[str, ModelRate]:
return {
model_id: ModelRate(
rates: dict[str, ModelRate] = {}
for model_id, values in _DEFAULT_RATES.items():
if len(values) == 3:
prompt_rate, completion_rate, captured_at = values # type: ignore[misc]
else:
prompt_rate, completion_rate = values # type: ignore[misc]
captured_at = DEFAULT_RATE_CAPTURED_AT
rates[model_id] = ModelRate(
model_id=model_id,
prompt_per_1k=prompt_rate,
completion_per_1k=completion_rate,
currency=DEFAULT_RATE_CURRENCY,
source_url=DEFAULT_RATE_SOURCE_URL,
captured_at=DEFAULT_RATE_CAPTURED_AT,
captured_at=str(captured_at),
)
for model_id, (prompt_rate, completion_rate) in _DEFAULT_RATES.items()
}
return rates
def _coerce_rate(model_id: str, rate: ModelRate | Mapping[str, Any]) -> ModelRate:

View file

@ -45,6 +45,7 @@ from llm_connect.exceptions import (
)
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.profiles import ProfiledLLMAdapter, default_runtime_profiles
from llm_connect.usage import maybe_record_usage, suppress_auto_usage_record
class _Handler(BaseHTTPRequestHandler):
@ -101,8 +102,19 @@ class _Handler(BaseHTTPRequestHandler):
"Adapter rejected RunConfig",
context={"model_name": config.model_name},
)
response = adapter.execute_prompt(prompt, config)
with suppress_auto_usage_record():
response = adapter.execute_prompt(prompt, config)
latency = time.time() - start
provider = (
(response.metadata or {}).get("provider")
or getattr(adapter, "provider_id", None)
or type(adapter).__name__
)
maybe_record_usage(
response,
provider=str(provider),
source="server",
)
body = response.to_dict()
debug = diagnostics.to_dict() if diagnostics is not None else None
if debug_enabled and debug is not None:

473
llm_connect/usage.py Normal file
View file

@ -0,0 +1,473 @@
"""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}"