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
147
README.md
147
README.md
|
|
@ -35,7 +35,8 @@ pip install llm-connect
|
|||
```python
|
||||
from llm_connect import create_adapter
|
||||
|
||||
# OpenRouter
|
||||
# OpenRouter (default model: moonshotai/kimi-k3)
|
||||
adapter = create_adapter("openrouter")
|
||||
adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4")
|
||||
|
||||
# Gemini (uses GEMINI_API_KEY env var or apikey-geminifree.txt)
|
||||
|
|
@ -48,6 +49,34 @@ adapter = create_adapter("openai", model="gpt-4.1-mini")
|
|||
adapter = create_adapter("claude-code")
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Run a prompt; content on stdout, tokens + €/USD cost on stderr
|
||||
llm-connect run "Summarise the value chain concept."
|
||||
llm-connect run "Hello" --provider mock --model moonshotai/kimi-k3 --json
|
||||
|
||||
# Estimate cost without calling a provider
|
||||
llm-connect cost estimate --model moonshotai/kimi-k3 \
|
||||
--prompt-tokens 1000 --completion-tokens 500
|
||||
|
||||
# Weekly spend (Mon-based; default timezone Europe/Berlin)
|
||||
llm-connect spend week # current week: Mon 00:00 → now
|
||||
llm-connect spend week --last # previous week: Mon → Sun
|
||||
llm-connect spend week --json
|
||||
```
|
||||
|
||||
Usage events append to a JSONL ledger (default
|
||||
`~/.local/share/llm-connect/usage.jsonl`; override with `--ledger` or
|
||||
`LLM_CONNECT_USAGE_LEDGER`). Costs are list-price estimates (USD rate table +
|
||||
EUR via snapshot or `LLM_CONNECT_EUR_PER_USD`).
|
||||
|
||||
| Env | Purpose |
|
||||
|---|---|
|
||||
| `LLM_CONNECT_USAGE_LEDGER` | Usage JSONL path; enables library/server auto-recording when set |
|
||||
| `LLM_CONNECT_EUR_PER_USD` | Euros per one USD (FX override) |
|
||||
| `LLM_CONNECT_TZ` | Timezone for weekly spend windows (default `Europe/Berlin`) |
|
||||
|
||||
## API keys
|
||||
|
||||
Keys are resolved in this order (first found wins):
|
||||
|
|
@ -73,15 +102,15 @@ config = RunConfig(
|
|||
)
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `model_name` | `"gpt-4"` | Model identifier (adapter may override) |
|
||||
| `temperature` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `2000` | Maximum output tokens |
|
||||
| `model_params` | `{}` | Portable extras translated by each adapter; see `docs/adapter-model-params.md` |
|
||||
| `max_depth` | `3` | Max nesting depth for recursive calls |
|
||||
| `skip_if_exists` | `True` | Skip if identical input hash already processed |
|
||||
| `timeout_seconds` | `300` | Request timeout |
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `model_name` | `"gpt-4"` | Model identifier (adapter may override) |
|
||||
| `temperature` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `2000` | Maximum output tokens |
|
||||
| `model_params` | `{}` | Portable extras translated by each adapter; see `docs/adapter-model-params.md` |
|
||||
| `max_depth` | `3` | Max nesting depth for recursive calls |
|
||||
| `skip_if_exists` | `True` | Skip if identical input hash already processed |
|
||||
| `timeout_seconds` | `300` | Request timeout |
|
||||
|
||||
### `LLMResponse`
|
||||
|
||||
|
|
@ -92,55 +121,55 @@ response = adapter.execute_prompt(prompt, config)
|
|||
print(response.content) # generated text
|
||||
print(response.model) # model actually used
|
||||
print(response.usage) # {"prompt_tokens": …, "completion_tokens": …, "total_tokens": …}
|
||||
print(response.finish_reason) # "stop", "length", etc.
|
||||
```
|
||||
|
||||
## Server diagnostics
|
||||
|
||||
Serve mode can include a debug envelope without changing normal responses:
|
||||
|
||||
```bash
|
||||
LLM_CONNECT_DEBUG=1 python -m llm_connect.server --provider openrouter
|
||||
curl 'http://127.0.0.1:8080/execute?debug=1' -d '{"prompt":"hi"}'
|
||||
```
|
||||
|
||||
Set `LLM_CONNECT_AUDIT_DIR=/path/to/audit` to write per-call replay records,
|
||||
then parse one without another provider call:
|
||||
|
||||
```bash
|
||||
python -m llm_connect.replay /path/to/audit/record.json --json
|
||||
```
|
||||
|
||||
## Server runtime profiles
|
||||
|
||||
Serve mode enables named runtime profiles by default. A client can send
|
||||
`config.model_name="custodian-triage-balanced"` and the server resolves it to
|
||||
the configured provider/model before calling the adapter.
|
||||
|
||||
Useful runtime environment variables:
|
||||
|
||||
```bash
|
||||
LLM_CONNECT_HOST=0.0.0.0
|
||||
LLM_CONNECT_PORT=8080
|
||||
LLM_CONNECT_PROVIDER=openrouter
|
||||
LLM_CONNECT_MODEL=google/gemini-2.5-flash
|
||||
LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER=openrouter
|
||||
LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL=google/gemini-2.5-flash
|
||||
```
|
||||
|
||||
For local smoke tests without provider credentials:
|
||||
|
||||
```bash
|
||||
export LLM_CONNECT_MOCK_RESPONSE="$(python -c 'import json; print(json.dumps(json.load(open("fixtures/activity_core/daily-triage-valid-content.json"))))')"
|
||||
python -m llm_connect.server --provider mock
|
||||
python scripts/smoke_activity_core_endpoint.py --url http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
Disable profile dispatch with `--disable-profiles`. Set
|
||||
`LLM_CONNECT_STRICT_PROFILES=1` or pass `--strict-profiles` to reject direct
|
||||
model names that are not configured profiles.
|
||||
|
||||
## Writing your own adapter
|
||||
print(response.finish_reason) # "stop", "length", etc.
|
||||
```
|
||||
|
||||
## Server diagnostics
|
||||
|
||||
Serve mode can include a debug envelope without changing normal responses:
|
||||
|
||||
```bash
|
||||
LLM_CONNECT_DEBUG=1 python -m llm_connect.server --provider openrouter
|
||||
curl 'http://127.0.0.1:8080/execute?debug=1' -d '{"prompt":"hi"}'
|
||||
```
|
||||
|
||||
Set `LLM_CONNECT_AUDIT_DIR=/path/to/audit` to write per-call replay records,
|
||||
then parse one without another provider call:
|
||||
|
||||
```bash
|
||||
python -m llm_connect.replay /path/to/audit/record.json --json
|
||||
```
|
||||
|
||||
## Server runtime profiles
|
||||
|
||||
Serve mode enables named runtime profiles by default. A client can send
|
||||
`config.model_name="custodian-triage-balanced"` and the server resolves it to
|
||||
the configured provider/model before calling the adapter.
|
||||
|
||||
Useful runtime environment variables:
|
||||
|
||||
```bash
|
||||
LLM_CONNECT_HOST=0.0.0.0
|
||||
LLM_CONNECT_PORT=8080
|
||||
LLM_CONNECT_PROVIDER=openrouter
|
||||
LLM_CONNECT_MODEL=google/gemini-2.5-flash
|
||||
LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER=openrouter
|
||||
LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL=google/gemini-2.5-flash
|
||||
```
|
||||
|
||||
For local smoke tests without provider credentials:
|
||||
|
||||
```bash
|
||||
export LLM_CONNECT_MOCK_RESPONSE="$(python -c 'import json; print(json.dumps(json.load(open("fixtures/activity_core/daily-triage-valid-content.json"))))')"
|
||||
python -m llm_connect.server --provider mock
|
||||
python scripts/smoke_activity_core_endpoint.py --url http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
Disable profile dispatch with `--disable-profiles`. Set
|
||||
`LLM_CONNECT_STRICT_PROFILES=1` or pass `--strict-profiles` to reject direct
|
||||
model names that are not configured profiles.
|
||||
|
||||
## Writing your own adapter
|
||||
|
||||
```python
|
||||
from llm_connect import LLMAdapter, RunConfig, LLMResponse
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Cost Estimates
|
||||
|
||||
`llm_connect.costs` converts token estimates or observed token counts into
|
||||
USD estimates using `ModelRateRegistry`.
|
||||
USD estimates using `ModelRateRegistry`, and optionally into EUR via
|
||||
`llm_connect.fx`.
|
||||
|
||||
## Contract
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ USD estimates using `ModelRateRegistry`.
|
|||
from llm_connect import estimate_cost
|
||||
|
||||
estimate = estimate_cost("openai/gpt-4o-mini", 28_000, 7_500)
|
||||
estimate = estimate_cost("moonshotai/kimi-k3", 1_000, 500, fx=0.92)
|
||||
```
|
||||
|
||||
For known models the result is:
|
||||
|
|
@ -17,9 +19,16 @@ For known models the result is:
|
|||
- `prompt_cost_usd`: prompt-token component.
|
||||
- `completion_cost_usd`: completion-token component.
|
||||
- `cost_source`: `rate_table:<model_id>`.
|
||||
- `cost_eur` / `prompt_cost_eur` / `completion_cost_eur`: EUR display amounts
|
||||
when FX is available (default: bundled snapshot, override with `fx=` or
|
||||
env `LLM_CONNECT_EUR_PER_USD` as euros-per-USD).
|
||||
- `fx_source`: how EUR was derived (`explicit`, `env:…`, `snapshot:…`).
|
||||
|
||||
Unknown models return `CostEstimate(cost_usd=None, cost_source="unknown")`.
|
||||
Missing rates are never silently treated as zero cost.
|
||||
Unknown models return `CostEstimate(cost_usd=None, cost_source="unknown")`
|
||||
with EUR fields also `None`. Missing rates or FX are never silently treated
|
||||
as zero cost.
|
||||
|
||||
The module also exposes `CostModel(registry=...)` for callers that prefer to
|
||||
carry a registry object and call `model.estimate_cost(...)`.
|
||||
Pass `apply_fx=False` to skip EUR conversion.
|
||||
|
||||
The module also exposes `CostModel(registry=..., fx=...)` for callers that
|
||||
prefer to carry a registry object and call `model.estimate_cost(...)`.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ post-hoc estimates.
|
|||
- `ModelRate` records `model_id`, prompt and completion rates in USD per
|
||||
1,000 tokens, `currency`, `source_url`, and `captured_at`.
|
||||
- `ModelRateRegistry.default()` returns the bundled OpenRouter snapshot
|
||||
captured on `2026-05-17`.
|
||||
(base rates captured `2026-05-17`; `moonshotai/kimi-k3` captured
|
||||
`2026-08-03` at `$0.003` / `$0.015` per 1k prompt/completion).
|
||||
- `ModelRateRegistry.from_yaml(path)` accepts the package/consumer override
|
||||
shape:
|
||||
|
||||
|
|
@ -21,10 +22,14 @@ rates:
|
|||
openai/gpt-4o-mini:
|
||||
prompt_per_1k: 0.00015
|
||||
completion_per_1k: 0.00060
|
||||
moonshotai/kimi-k3:
|
||||
prompt_per_1k: 0.003
|
||||
completion_per_1k: 0.015
|
||||
```
|
||||
|
||||
- `merged_with(override)` returns a new registry where matching override
|
||||
entries replace default entries by `model_id`.
|
||||
|
||||
Rates are a static snapshot. Consumers decide whether `captured_at` is fresh
|
||||
enough for their workflow.
|
||||
enough for their workflow. List prices remain USD; EUR display conversion is
|
||||
handled by `llm_connect.fx` / `estimate_cost`.
|
||||
|
|
|
|||
33
contracts/functional/usage-ledger.md
Normal file
33
contracts/functional/usage-ledger.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Usage Ledger and Spend Windows
|
||||
|
||||
`llm_connect.usage` stores append-only spend events for operator cost reporting.
|
||||
It is separate from `QualityLedger` (adaptive routing quality signals).
|
||||
|
||||
## UsageEvent
|
||||
|
||||
Each event records provider, model, token counts, estimated `cost_usd` /
|
||||
`cost_eur`, cost/FX sources, call `source` (`cli` | `server` | `library`),
|
||||
and `recorded_at` (UTC).
|
||||
|
||||
## Paths
|
||||
|
||||
1. Explicit `--ledger` / constructor path
|
||||
2. Env `LLM_CONNECT_USAGE_LEDGER`
|
||||
3. `$XDG_DATA_HOME/llm-connect/usage.jsonl`
|
||||
4. `~/.local/share/llm-connect/usage.jsonl`
|
||||
|
||||
Library and server auto-recording only runs when `LLM_CONNECT_USAGE_LEDGER` is
|
||||
set (opt-in). CLI `run` records by default to the resolved path unless
|
||||
`--no-ledger`.
|
||||
|
||||
## Week windows
|
||||
|
||||
`week_window("current"|"last", tz=...)` returns Monday-based half-open ranges
|
||||
in local time (default `Europe/Berlin`, override with `LLM_CONNECT_TZ` or
|
||||
`--tz`):
|
||||
|
||||
- **current**: Monday 00:00 → now
|
||||
- **last**: previous Monday 00:00 → this Monday 00:00 (full Mon–Sun week)
|
||||
|
||||
`UsageLedger.sum_range(start, end)` aggregates tokens and costs for
|
||||
`[start, end)`.
|
||||
|
|
@ -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}"
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import json
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from llm_connect.cli import main
|
||||
from llm_connect.quality import QualityLedger, QualityObservation
|
||||
from llm_connect.usage import UsageEvent, UsageLedger
|
||||
|
||||
|
||||
def test_rates_show_json_outputs_default_registry(capsys):
|
||||
|
|
@ -11,6 +13,7 @@ def test_rates_show_json_outputs_default_registry(capsys):
|
|||
payload = json.loads(capsys.readouterr().out)
|
||||
|
||||
assert payload["openai/gpt-4o-mini"]["prompt_per_1k"] == 0.00015
|
||||
assert payload["moonshotai/kimi-k3"]["prompt_per_1k"] == 0.003
|
||||
|
||||
|
||||
def test_classes_show_lists_builtins(capsys):
|
||||
|
|
@ -52,3 +55,116 @@ def test_classes_fit_reads_quality_ledger(tmp_path, capsys):
|
|||
payload = json.loads(capsys.readouterr().out)
|
||||
|
||||
assert payload["entity-extraction"]["params"]["tokens_per_entity"] == 70
|
||||
|
||||
|
||||
def test_run_with_mock_reports_cost_and_writes_ledger(tmp_path, capsys):
|
||||
ledger = tmp_path / "usage.jsonl"
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"run",
|
||||
"hello world",
|
||||
"--provider",
|
||||
"mock",
|
||||
"--model",
|
||||
"moonshotai/kimi-k3",
|
||||
"--ledger",
|
||||
str(ledger),
|
||||
"--eur-per-usd",
|
||||
"1.0",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["content"]
|
||||
assert payload["usage"]["total_tokens"] > 0
|
||||
assert payload["cost_usd"] is not None
|
||||
assert payload["cost_eur"] is not None
|
||||
events = UsageLedger(ledger).read_all()
|
||||
assert len(events) == 1
|
||||
assert events[0].source == "cli"
|
||||
assert events[0].model_id == "moonshotai/kimi-k3"
|
||||
|
||||
|
||||
def test_cost_estimate_cli(capsys):
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"cost",
|
||||
"estimate",
|
||||
"--model",
|
||||
"moonshotai/kimi-k3",
|
||||
"--prompt-tokens",
|
||||
"1000",
|
||||
"--completion-tokens",
|
||||
"1000",
|
||||
"--eur-per-usd",
|
||||
"1",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["cost_usd"] == 0.018
|
||||
assert payload["cost_eur"] == 0.018
|
||||
|
||||
|
||||
def test_spend_week_current_and_last(tmp_path, capsys, monkeypatch):
|
||||
ledger_path = tmp_path / "usage.jsonl"
|
||||
ledger = UsageLedger(ledger_path)
|
||||
berlin = ZoneInfo("Europe/Berlin")
|
||||
current_start = datetime(2026, 8, 3, 0, 0, tzinfo=berlin)
|
||||
current_end = datetime(2026, 8, 5, 12, 0, tzinfo=berlin)
|
||||
last_start = datetime(2026, 7, 27, 0, 0, tzinfo=berlin)
|
||||
last_end = current_start
|
||||
|
||||
def _fake_week_window(which="current", *, now=None, tz=None):
|
||||
if which == "last":
|
||||
return last_start, last_end
|
||||
return current_start, current_end
|
||||
|
||||
monkeypatch.setattr("llm_connect.cli.week_window", _fake_week_window)
|
||||
|
||||
ledger.append(
|
||||
UsageEvent(
|
||||
provider="mock",
|
||||
model_id="moonshotai/kimi-k3",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=20,
|
||||
total_tokens=120,
|
||||
cost_usd=0.01,
|
||||
cost_eur=0.009,
|
||||
cost_source="rate_table:moonshotai/kimi-k3",
|
||||
source="cli",
|
||||
recorded_at=datetime(2026, 8, 4, 9, 0, tzinfo=berlin),
|
||||
)
|
||||
)
|
||||
ledger.append(
|
||||
UsageEvent(
|
||||
provider="mock",
|
||||
model_id="moonshotai/kimi-k3",
|
||||
prompt_tokens=50,
|
||||
completion_tokens=10,
|
||||
total_tokens=60,
|
||||
cost_usd=0.005,
|
||||
cost_eur=0.0045,
|
||||
cost_source="rate_table:moonshotai/kimi-k3",
|
||||
source="cli",
|
||||
recorded_at=datetime(2026, 7, 29, 9, 0, tzinfo=berlin), # previous week
|
||||
)
|
||||
)
|
||||
|
||||
assert main(["spend", "week", "--ledger", str(ledger_path), "--json"]) == 0
|
||||
current = json.loads(capsys.readouterr().out)
|
||||
assert current["event_count"] == 1
|
||||
assert current["total_tokens"] == 120
|
||||
assert current["cost_eur"] == 0.009
|
||||
|
||||
assert main(["spend", "week", "--last", "--ledger", str(ledger_path), "--json"]) == 0
|
||||
last = json.loads(capsys.readouterr().out)
|
||||
assert last["event_count"] == 1
|
||||
assert last["total_tokens"] == 60
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ def test_known_model_cost_matches_lefevre_smoke_budget():
|
|||
assert estimate.cost_source == "rate_table:openai/gpt-4o-mini"
|
||||
assert estimate.cost_usd == pytest.approx(0.0087)
|
||||
assert estimate.cost_usd == pytest.approx(0.009, rel=0.2)
|
||||
assert estimate.cost_eur is not None
|
||||
assert estimate.fx_source is not None
|
||||
|
||||
|
||||
def test_unknown_model_returns_unknown_without_zeroing_cost():
|
||||
estimate = estimate_cost("unknown/model", 100, 50)
|
||||
|
||||
assert estimate == CostEstimate(cost_usd=None, cost_source="unknown")
|
||||
assert estimate.cost_usd is None
|
||||
assert estimate.cost_eur is None
|
||||
assert estimate.cost_source == "unknown"
|
||||
|
||||
|
||||
def test_registry_override_controls_estimate():
|
||||
|
|
@ -29,21 +33,40 @@ def test_registry_override_controls_estimate():
|
|||
}
|
||||
)
|
||||
|
||||
estimate = estimate_cost("vendor/model", 1_000, 500, registry=registry)
|
||||
estimate = estimate_cost("vendor/model", 1_000, 500, registry=registry, fx=1.0)
|
||||
|
||||
assert estimate.cost_usd == pytest.approx(2.0)
|
||||
assert estimate.prompt_cost_usd == pytest.approx(1.0)
|
||||
assert estimate.completion_cost_usd == pytest.approx(1.0)
|
||||
assert estimate.cost_eur == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_zero_tokens_are_valid_and_cost_zero_for_known_model():
|
||||
estimate = CostModel().estimate_cost("openai/gpt-4o-mini", 0, 0)
|
||||
estimate = CostModel(fx=1.0).estimate_cost("openai/gpt-4o-mini", 0, 0)
|
||||
|
||||
assert estimate.cost_usd == 0
|
||||
assert estimate.prompt_cost_usd == 0
|
||||
assert estimate.completion_cost_usd == 0
|
||||
assert estimate.cost_eur == 0
|
||||
|
||||
|
||||
def test_negative_tokens_are_rejected():
|
||||
with pytest.raises(ValueError, match="prompt_tokens"):
|
||||
estimate_cost("openai/gpt-4o-mini", -1, 0)
|
||||
|
||||
|
||||
def test_kimi_k3_rate_and_eur_conversion():
|
||||
estimate = estimate_cost("moonshotai/kimi-k3", 1_000, 1_000, fx=0.92)
|
||||
|
||||
# $0.003 + $0.015 = $0.018; ×0.92 = €0.01656
|
||||
assert estimate.cost_usd == pytest.approx(0.018)
|
||||
assert estimate.cost_eur == pytest.approx(0.01656)
|
||||
assert estimate.cost_source == "rate_table:moonshotai/kimi-k3"
|
||||
|
||||
|
||||
def test_apply_fx_false_skips_eur():
|
||||
estimate = estimate_cost("moonshotai/kimi-k3", 100, 0, apply_fx=False)
|
||||
|
||||
assert estimate.cost_usd is not None
|
||||
assert estimate.cost_eur is None
|
||||
assert estimate.fx_source is None
|
||||
|
|
|
|||
40
tests/test_fx.py
Normal file
40
tests/test_fx.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import pytest
|
||||
|
||||
from llm_connect.fx import DEFAULT_EUR_PER_USD, FxRate, resolve_fx_rate
|
||||
|
||||
|
||||
def test_resolve_fx_prefers_explicit_rate():
|
||||
rate = resolve_fx_rate(0.85)
|
||||
|
||||
assert rate is not None
|
||||
assert rate.eur_per_usd == pytest.approx(0.85)
|
||||
assert rate.source == "explicit"
|
||||
assert rate.usd_to_eur(10.0) == pytest.approx(8.5)
|
||||
|
||||
|
||||
def test_resolve_fx_reads_env(monkeypatch):
|
||||
monkeypatch.setenv("LLM_CONNECT_EUR_PER_USD", "0.9")
|
||||
|
||||
rate = resolve_fx_rate()
|
||||
|
||||
assert rate is not None
|
||||
assert rate.eur_per_usd == pytest.approx(0.9)
|
||||
assert rate.source == "env:LLM_CONNECT_EUR_PER_USD"
|
||||
|
||||
|
||||
def test_resolve_fx_falls_back_to_snapshot():
|
||||
rate = resolve_fx_rate(env={})
|
||||
|
||||
assert rate is not None
|
||||
assert rate.eur_per_usd == pytest.approx(DEFAULT_EUR_PER_USD)
|
||||
assert rate.source.startswith("snapshot:")
|
||||
|
||||
|
||||
def test_invalid_env_rate_raises():
|
||||
with pytest.raises(ValueError, match="LLM_CONNECT_EUR_PER_USD"):
|
||||
resolve_fx_rate(env={"LLM_CONNECT_EUR_PER_USD": "0"})
|
||||
|
||||
|
||||
def test_fx_rate_rejects_non_positive():
|
||||
with pytest.raises(ValueError, match="eur_per_usd"):
|
||||
FxRate(eur_per_usd=-1, source="x")
|
||||
|
|
@ -61,3 +61,21 @@ def test_wp_0006_profile_primitives_are_exported_from_package_root():
|
|||
for name in expected_names:
|
||||
assert hasattr(llm_connect, name)
|
||||
assert name in llm_connect.__all__
|
||||
|
||||
|
||||
def test_wp_0007_spend_primitives_are_exported_from_package_root():
|
||||
expected_names = [
|
||||
"FxRate",
|
||||
"resolve_fx_rate",
|
||||
"UsageEvent",
|
||||
"UsageLedger",
|
||||
"UsageSummary",
|
||||
"default_usage_ledger_path",
|
||||
"event_from_response",
|
||||
"maybe_record_usage",
|
||||
"week_window",
|
||||
]
|
||||
|
||||
for name in expected_names:
|
||||
assert hasattr(llm_connect, name)
|
||||
assert name in llm_connect.__all__
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ def test_default_registry_contains_openrouter_seed_models():
|
|||
registry = ModelRateRegistry.default()
|
||||
rates = registry.all()
|
||||
|
||||
assert len(rates) >= 9
|
||||
assert len(rates) >= 10
|
||||
assert rates["openai/gpt-4o-mini"].captured_at == "2026-05-17"
|
||||
assert rates["openai/gpt-4o-mini"].source_url == "https://openrouter.ai/models"
|
||||
assert rates["moonshotai/kimi-k3"].prompt_per_1k == 0.003
|
||||
assert rates["moonshotai/kimi-k3"].completion_per_1k == 0.015
|
||||
assert rates["moonshotai/kimi-k3"].captured_at == "2026-08-03"
|
||||
|
||||
|
||||
def test_from_yaml_loads_package_shape(tmp_path):
|
||||
|
|
|
|||
127
tests/test_usage.py
Normal file
127
tests/test_usage.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_connect.models import LLMResponse
|
||||
from llm_connect.usage import (
|
||||
UsageEvent,
|
||||
UsageLedger,
|
||||
default_usage_ledger_path,
|
||||
event_from_response,
|
||||
maybe_record_usage,
|
||||
suppress_auto_usage_record,
|
||||
week_window,
|
||||
)
|
||||
|
||||
|
||||
def test_default_ledger_path_env_and_xdg():
|
||||
assert default_usage_ledger_path(env={"LLM_CONNECT_USAGE_LEDGER": "/tmp/u.jsonl"}) == (
|
||||
__import__("pathlib").Path("/tmp/u.jsonl")
|
||||
)
|
||||
path = default_usage_ledger_path(env={"XDG_DATA_HOME": "/tmp/xdg"})
|
||||
assert path.as_posix().endswith("llm-connect/usage.jsonl")
|
||||
assert "xdg" in path.as_posix()
|
||||
|
||||
|
||||
def test_usage_ledger_append_and_sum_range(tmp_path):
|
||||
ledger = UsageLedger(tmp_path / "usage.jsonl")
|
||||
berlin = ZoneInfo("Europe/Berlin")
|
||||
monday = datetime(2026, 8, 3, 10, 0, tzinfo=berlin) # Monday
|
||||
sunday = datetime(2026, 8, 2, 12, 0, tzinfo=berlin) # previous Sunday
|
||||
|
||||
ledger.append(
|
||||
UsageEvent(
|
||||
provider="openrouter",
|
||||
model_id="moonshotai/kimi-k3",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
total_tokens=150,
|
||||
cost_usd=0.001,
|
||||
cost_eur=0.00092,
|
||||
cost_source="rate_table:moonshotai/kimi-k3",
|
||||
fx_source="snapshot:2026-08-03",
|
||||
source="cli",
|
||||
recorded_at=monday,
|
||||
)
|
||||
)
|
||||
ledger.append(
|
||||
UsageEvent(
|
||||
provider="openrouter",
|
||||
model_id="moonshotai/kimi-k3",
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
total_tokens=15,
|
||||
cost_usd=0.0001,
|
||||
cost_eur=0.000092,
|
||||
cost_source="rate_table:moonshotai/kimi-k3",
|
||||
source="cli",
|
||||
recorded_at=sunday,
|
||||
)
|
||||
)
|
||||
|
||||
start, end = week_window(
|
||||
"current",
|
||||
now=datetime(2026, 8, 5, 18, 0, tzinfo=berlin),
|
||||
tz="Europe/Berlin",
|
||||
)
|
||||
summary = ledger.sum_range(start, end)
|
||||
|
||||
assert summary.event_count == 1
|
||||
assert summary.prompt_tokens == 100
|
||||
assert summary.total_tokens == 150
|
||||
assert summary.cost_eur == pytest.approx(0.00092)
|
||||
|
||||
last_start, last_end = week_window(
|
||||
"last",
|
||||
now=datetime(2026, 8, 5, 18, 0, tzinfo=berlin),
|
||||
tz="Europe/Berlin",
|
||||
)
|
||||
last_summary = ledger.sum_range(last_start, last_end)
|
||||
assert last_summary.event_count == 1
|
||||
assert last_summary.total_tokens == 15
|
||||
|
||||
|
||||
def test_week_window_current_and_last():
|
||||
berlin = ZoneInfo("Europe/Berlin")
|
||||
now = datetime(2026, 8, 5, 15, 30, tzinfo=berlin) # Wednesday
|
||||
start, end = week_window("current", now=now, tz="Europe/Berlin")
|
||||
assert start.weekday() == 0
|
||||
assert start.hour == 0
|
||||
assert end == now
|
||||
|
||||
last_start, last_end = week_window("last", now=now, tz="Europe/Berlin")
|
||||
assert (last_end - last_start).days == 7
|
||||
assert last_end == start
|
||||
|
||||
|
||||
def test_event_from_response_estimates_kimi_cost():
|
||||
response = LLMResponse(
|
||||
content="hi",
|
||||
model="moonshotai/kimi-k3",
|
||||
usage={"prompt_tokens": 1000, "completion_tokens": 1000, "total_tokens": 2000},
|
||||
)
|
||||
event = event_from_response(response, provider="openrouter", source="cli", fx=1.0)
|
||||
|
||||
assert event.cost_usd == pytest.approx(0.018)
|
||||
assert event.cost_eur == pytest.approx(0.018)
|
||||
assert event.cost_source == "rate_table:moonshotai/kimi-k3"
|
||||
|
||||
|
||||
def test_maybe_record_usage_opt_in(tmp_path, monkeypatch):
|
||||
ledger = tmp_path / "usage.jsonl"
|
||||
monkeypatch.setenv("LLM_CONNECT_USAGE_LEDGER", str(ledger))
|
||||
response = LLMResponse(
|
||||
content="x",
|
||||
model="moonshotai/kimi-k3",
|
||||
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
metadata={"provider": "openrouter"},
|
||||
)
|
||||
|
||||
event = maybe_record_usage(response, provider="openrouter", source="library")
|
||||
assert event is not None
|
||||
assert ledger.is_file()
|
||||
assert UsageLedger(ledger).read_all()[0].source == "library"
|
||||
|
||||
with suppress_auto_usage_record():
|
||||
assert maybe_record_usage(response, provider="openrouter", source="library") is None
|
||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Kimi K3 default via OpenRouter + EUR spend reporting"
|
||||
domain: agents
|
||||
repo: llm-connect
|
||||
status: ready
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: kimi-k3-default-spend-reporting
|
||||
planning_priority: high
|
||||
|
|
@ -20,7 +20,7 @@ state_hub_workstream_id: "a93d226a-f5c4-4656-b584-a783840b10fe"
|
|||
|
||||
# LLM-WP-0007 — Kimi K3 default via OpenRouter + EUR spend reporting
|
||||
|
||||
**status:** ready
|
||||
**status:** finished
|
||||
**owner:** codex
|
||||
|
||||
## Purpose
|
||||
|
|
@ -173,7 +173,7 @@ print clear `unknown` when not.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "af12101d-bcd5-4d6f-9507-c81de79a0685"
|
||||
```
|
||||
|
|
@ -186,7 +186,7 @@ or README providers table. Gate for T02.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ca836db8-9041-42e5-87ce-7c3ace1298a3"
|
||||
```
|
||||
|
|
@ -199,7 +199,7 @@ leave default unchanged and record the blocker in the task notes.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f379d09b-ea03-44f5-82fa-171da4681cef"
|
||||
```
|
||||
|
|
@ -211,7 +211,7 @@ model still yields `cost_usd=None` / `cost_eur=None`.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "cadf1b0c-fdea-4446-8d01-964b2706540d"
|
||||
```
|
||||
|
|
@ -224,7 +224,7 @@ effort flock), and range aggregation.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8956fd74-cad4-435c-ae66-6b19088691e4"
|
||||
```
|
||||
|
|
@ -236,7 +236,7 @@ estimation from response usage + rate table + FX. Tests with mock adapter.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ac5e4322-cd00-4034-b162-a0e1c481180e"
|
||||
```
|
||||
|
|
@ -248,7 +248,7 @@ fixed timezone.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "a05761b9-d91c-4b6d-9e90-296aadadaf16"
|
||||
```
|
||||
|
|
@ -260,7 +260,7 @@ opt-in so library consumers are not surprised by home-dir writes.
|
|||
|
||||
```task
|
||||
id: LLM-WP-0007-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "94ce6aee-2835-46c0-a3e6-3d0345e7715f"
|
||||
```
|
||||
|
|
@ -272,14 +272,14 @@ as needed. Smoke script or Makefile target for optional live Kimi check.
|
|||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `moonshotai/kimi-k3` is in the default rate registry with documented USD rates
|
||||
- [ ] After successful smoke, OpenRouter/library default model is `moonshotai/kimi-k3`
|
||||
- [ ] `llm-connect run "..."` prints token counts and € cost (or explicit unknown)
|
||||
- [ ] Successful CLI runs append to the usage ledger
|
||||
- [ ] `llm-connect spend week` and `spend week --last` show tokens + € for the
|
||||
- [x] `moonshotai/kimi-k3` is in the default rate registry with documented USD rates
|
||||
- [x] After successful smoke, OpenRouter/library default model is `moonshotai/kimi-k3`
|
||||
- [x] `llm-connect run "..."` prints token counts and € cost (or explicit unknown)
|
||||
- [x] Successful CLI runs append to the usage ledger
|
||||
- [x] `llm-connect spend week` and `spend week --last` show tokens + € for the
|
||||
correct Mon-based windows
|
||||
- [ ] Default CI tests pass offline (mock adapter, fixed FX, no live OpenRouter)
|
||||
- [ ] Missing rate or FX never reported as €0.00 without an `unknown` marker
|
||||
- [x] Default CI tests pass offline (mock adapter, fixed FX, no live OpenRouter)
|
||||
- [x] Missing rate or FX never reported as €0.00 without an `unknown` marker
|
||||
|
||||
## Risks / notes
|
||||
|
||||
|
|
@ -298,3 +298,12 @@ as needed. Smoke script or Makefile target for optional live Kimi check.
|
|||
|
||||
T01 → T02 (gated) in parallel with T03 → T04 → T05 → T06 → T07 → T08.
|
||||
T03/T04 can start before T01; T02 must wait for T01 smoke outcome.
|
||||
|
||||
|
||||
## Implementation notes (2026-08-03)
|
||||
|
||||
- Live OpenRouter smoke: `moonshotai/kimi-k3` returned content "ok" with usage tokens (Together provider). Note: short `max_tokens` can yield empty `content` while spending tokens on reasoning.
|
||||
- Default OpenRouter / LLMConfig model flipped to `moonshotai/kimi-k3`.
|
||||
- Added `fx.py`, EUR fields on `CostEstimate`, `usage.py` ledger, CLI `run` / `cost estimate` / `spend week`.
|
||||
- Opt-in library/server recording via `LLM_CONNECT_USAGE_LEDGER`; CLI suppresses double-record.
|
||||
- Full unit suite: 224 passed offline.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue