llm-connect/llm_connect/cli.py
tegwick ed7c632155
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Implement LLM-WP-0008: provider-scoped account balance CLI
Add pluggable balance registry, OpenRouter credits/key limit client,
and llm-connect balance with one-shot --provider that does not change
library defaults.
2026-08-03 23:49:01 +02:00

410 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""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.balance import (
format_balance_human,
get_account_balance,
)
from llm_connect.costs import estimate_cost
from llm_connect.exceptions import LLMBalanceUnsupportedError, LLMConfigurationError, LLMError
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:
"""Run the ``llm-connect`` command."""
parser = _build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="llm-connect")
commands = parser.add_subparsers(dest="command", required=True)
rates = commands.add_parser("rates", help="Inspect model rate registries")
rate_commands = rates.add_subparsers(dest="rates_command", required=True)
rate_show = rate_commands.add_parser("show", help="Show model rates")
rate_show.add_argument("--rates", type=Path, help="YAML registry overlay")
rate_show.add_argument("--json", action="store_true", help="Emit JSON")
rate_show.set_defaults(func=_rates_show)
classes = commands.add_parser("classes", help="Inspect problem classes")
class_commands = classes.add_subparsers(dest="classes_command", required=True)
class_show = class_commands.add_parser("show", help="Show problem classes")
class_show.add_argument("--json", action="store_true", help="Emit JSON")
class_show.set_defaults(func=_classes_show)
class_fit = class_commands.add_parser("fit", help="Fit problem-class params from a ledger")
class_fit.add_argument("ledger", type=Path, help="QualityLedger JSONL path")
class_fit.add_argument("--class", dest="class_name", help="Fit one class by name")
class_fit.add_argument("--min-observations", type=int, default=3)
class_fit.add_argument("--json", action="store_true", help="Emit JSON")
class_fit.set_defaults(func=_classes_fit)
run = commands.add_parser("run", help="Execute a prompt and report tokens + cost")
run.add_argument("prompt", help="Prompt text to send")
run.add_argument("--provider", default="openrouter", help="Provider key (default openrouter)")
run.add_argument("--model", default=None, help="Model id (provider default if omitted)")
run.add_argument("--temperature", type=float, default=0.7)
run.add_argument("--max-tokens", type=int, default=2000)
run.add_argument("--timeout", type=int, default=300, help="Timeout seconds")
run.add_argument("--ledger", type=Path, default=None, help="Usage ledger JSONL path")
run.add_argument("--no-ledger", action="store_true", help="Do not append to the usage ledger")
run.add_argument("--json", action="store_true", help="Emit JSON response + cost")
run.add_argument(
"--eur-per-usd",
type=float,
default=None,
help="Override FX rate (euros per one USD)",
)
run.set_defaults(func=_run_prompt)
cost = commands.add_parser("cost", help="Cost estimation helpers")
cost_commands = cost.add_subparsers(dest="cost_command", required=True)
cost_estimate = cost_commands.add_parser(
"estimate",
help="Estimate cost for token counts without calling a provider",
)
cost_estimate.add_argument("--model", required=True, help="Model id")
cost_estimate.add_argument("--prompt-tokens", type=int, required=True)
cost_estimate.add_argument("--completion-tokens", type=int, default=0)
cost_estimate.add_argument("--rates", type=Path, help="YAML registry overlay")
cost_estimate.add_argument("--eur-per-usd", type=float, default=None)
cost_estimate.add_argument("--json", action="store_true", help="Emit JSON")
cost_estimate.set_defaults(func=_cost_estimate)
spend = commands.add_parser("spend", help="Inspect recorded spend")
spend_commands = spend.add_subparsers(dest="spend_command", required=True)
spend_week = spend_commands.add_parser(
"week",
help="Weekly spend: current Mon→now, or --last for previous MonSun",
)
spend_week.add_argument(
"--last",
action="store_true",
help="Previous calendar week (Mon 00:00 → Sun end / this Mon 00:00)",
)
spend_week.add_argument("--ledger", type=Path, default=None, help="Usage ledger JSONL path")
spend_week.add_argument("--tz", default=None, help="Timezone (default Europe/Berlin)")
spend_week.add_argument("--json", action="store_true", help="Emit JSON")
spend_week.set_defaults(func=_spend_week)
balance = commands.add_parser(
"balance",
help=(
"Show prepaid/account remaining for a backend "
"(default: current default provider; --provider is one-shot only)"
),
)
balance.add_argument(
"--provider",
default=None,
help=(
"Backend to query for this command only (does not change library defaults). "
"Omit to use the current default provider."
),
)
balance.add_argument("--json", action="store_true", help="Emit JSON")
balance.add_argument(
"--eur-per-usd",
type=float,
default=None,
help="Override FX rate (euros per one USD)",
)
balance.set_defaults(func=_balance_show)
return parser
def _rates_show(args: argparse.Namespace) -> int:
registry = ModelRateRegistry.default()
if args.rates:
registry = registry.merged_with(ModelRateRegistry.from_yaml(args.rates))
rates = registry.all()
if args.json:
print(
json.dumps(
{
model_id: {
"prompt_per_1k": rate.prompt_per_1k,
"completion_per_1k": rate.completion_per_1k,
"currency": rate.currency,
"source_url": rate.source_url,
"captured_at": rate.captured_at,
}
for model_id, rate in sorted(rates.items())
},
indent=2,
sort_keys=True,
)
)
return 0
print("model_id\tprompt_per_1k\tcompletion_per_1k\tcurrency\tcaptured_at")
for model_id, rate in sorted(rates.items()):
print(
f"{model_id}\t{rate.prompt_per_1k:g}\t{rate.completion_per_1k:g}\t"
f"{rate.currency}\t{rate.captured_at}"
)
return 0
def _classes_show(args: argparse.Namespace) -> int:
classes = ProblemClassRegistry.default().all()
if args.json:
print(json.dumps(_classes_payload(classes.values()), indent=2, sort_keys=True))
return 0
print("name\tdimensions\ttunable_params\tcurrent_params")
for problem_class in sorted(classes.values(), key=lambda item: item.name):
print(
f"{problem_class.name}\t{', '.join(problem_class.base_dimensions)}\t"
f"{', '.join(problem_class.tunable_params)}\t{_format_params(problem_class.params)}"
)
return 0
def _classes_fit(args: argparse.Namespace) -> int:
if args.min_observations <= 0:
raise SystemExit("--min-observations must be positive")
registry = ProblemClassRegistry.default()
classes = registry.all()
if args.class_name:
problem_class = registry.get(args.class_name)
if problem_class is None:
raise SystemExit(f"Unknown problem class: {args.class_name}")
selected: list[ProblemClass] = [problem_class]
else:
selected = list(classes.values())
observations = QualityLedger(args.ledger).read_all()
fitted: list[ProblemClass] = [
problem_class.fit(observations, min_observations=args.min_observations)
for problem_class in selected
]
if args.json:
print(json.dumps(_classes_payload(fitted), indent=2, sort_keys=True))
return 0
print("name\tfitted_params\tconfidence")
for problem_class in sorted(fitted, key=lambda item: item.name):
confidence = getattr(problem_class, "confidence", 0.5)
print(f"{problem_class.name}\t{_format_params(problem_class.params)}\t{confidence:g}")
return 0
def _run_prompt(args: argparse.Namespace) -> int:
fx = resolve_fx_rate(args.eur_per_usd)
adapter_kwargs: dict[str, Any] = {}
if args.model is not None:
adapter_kwargs["model"] = args.model
adapter = create_adapter(args.provider, **adapter_kwargs)
config = RunConfig(
model_name=args.model or "gpt-4",
temperature=args.temperature,
max_tokens=args.max_tokens,
timeout_seconds=args.timeout,
)
with suppress_auto_usage_record():
response = adapter.execute_prompt(args.prompt, config)
usage = response.usage or {}
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
model_id = response.model or args.model or "unknown"
estimate = estimate_cost(
model_id,
prompt_tokens,
completion_tokens,
fx=fx,
)
if not args.no_ledger:
ledger_path = args.ledger or default_usage_ledger_path()
event = event_from_response(
response,
provider=args.provider,
source="cli",
fx=fx,
)
UsageLedger(ledger_path).append(event)
footer = format_cost_footer(
estimate,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
model_id=model_id,
)
if args.json:
print(
json.dumps(
{
"content": response.content,
"model": model_id,
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
},
"cost_usd": estimate.cost_usd,
"cost_eur": estimate.cost_eur,
"cost_source": estimate.cost_source,
"fx_source": estimate.fx_source,
"finish_reason": response.finish_reason,
},
indent=2,
sort_keys=True,
)
)
return 0
print(response.content)
print(footer, file=sys.stderr)
return 0
def _cost_estimate(args: argparse.Namespace) -> int:
if args.prompt_tokens < 0 or args.completion_tokens < 0:
raise SystemExit("token counts must be non-negative")
registry = ModelRateRegistry.default()
if args.rates:
registry = registry.merged_with(ModelRateRegistry.from_yaml(args.rates))
estimate = estimate_cost(
args.model,
args.prompt_tokens,
args.completion_tokens,
registry=registry,
fx=args.eur_per_usd,
)
total = args.prompt_tokens + args.completion_tokens
if args.json:
print(
json.dumps(
{
"model": args.model,
"prompt_tokens": args.prompt_tokens,
"completion_tokens": args.completion_tokens,
"total_tokens": total,
"cost_usd": estimate.cost_usd,
"cost_eur": estimate.cost_eur,
"cost_source": estimate.cost_source,
"fx_source": estimate.fx_source,
},
indent=2,
sort_keys=True,
)
)
return 0
print(
format_cost_footer(
estimate,
prompt_tokens=args.prompt_tokens,
completion_tokens=args.completion_tokens,
total_tokens=total,
model_id=args.model,
)
)
return 0
def _spend_week(args: argparse.Namespace) -> int:
which = "last" if args.last else "current"
start, end = week_window(which, tz=args.tz)
ledger_path = args.ledger or default_usage_ledger_path()
summary = UsageLedger(ledger_path).sum_range(start, end)
if args.json:
payload = summary.to_dict()
payload["which"] = which
payload["tz"] = args.tz or "Europe/Berlin"
payload["ledger"] = str(ledger_path)
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
label = "last week (MonSun)" if which == "last" else "current week (Monnow)"
print(f"spend {label}")
print(f"window: {_fmt_local(start)}{_fmt_local(end)}")
print(f"ledger: {ledger_path}")
print(f"events: {summary.event_count}")
print(
f"tokens: in={summary.prompt_tokens} out={summary.completion_tokens} "
f"total={summary.total_tokens}"
)
usd = "unknown" if summary.cost_usd is None else f"{summary.cost_usd:.6f}"
eur = "unknown" if summary.cost_eur is None else f"{summary.cost_eur:.6f}"
print(f"cost: eur={eur} usd={usd}")
if summary.unknown_cost_events:
print(f"unknown_cost_events: {summary.unknown_cost_events}")
return 0
def _fmt_local(dt: datetime) -> str:
return dt.isoformat()
def _balance_show(args: argparse.Namespace) -> int:
try:
balance = get_account_balance(args.provider, fx=args.eur_per_usd)
except LLMBalanceUnsupportedError as exc:
print(str(exc), file=sys.stderr)
return 2
except LLMConfigurationError as exc:
print(str(exc), file=sys.stderr)
return 2
except LLMError as exc:
print(str(exc), file=sys.stderr)
return 1
if args.json:
print(json.dumps(balance.to_dict(), indent=2, sort_keys=True))
return 0
print(format_balance_human(balance))
return 0
def _classes_payload(classes: Iterable[ProblemClass]) -> dict[str, dict[str, Any]]:
return {
problem_class.name: {
"base_dimensions": list(problem_class.base_dimensions),
"tunable_params": list(problem_class.tunable_params),
"params": dict(problem_class.params),
"confidence": getattr(problem_class, "confidence", 0.5),
}
for problem_class in sorted(classes, key=lambda item: item.name)
}
def _format_params(params: Mapping[str, float]) -> str:
return ", ".join(f"{key}={value:g}" for key, value in sorted(dict(params).items()))
if __name__ == "__main__":
raise SystemExit(main())