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.
This commit is contained in:
parent
f3121c3f1f
commit
09aa1f3604
21 changed files with 1396 additions and 103 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 Mon–Sun",
|
||||
)
|
||||
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 (Mon–Sun)" if which == "last" else "current week (Mon–now)"
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
93
llm_connect/fx.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
473
llm_connect/usage.py
Normal 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 Mon–Sun)
|
||||
|
||||
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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue