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

@ -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,
)