Implement LLM-WP-0008: provider-scoped account balance CLI
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Add pluggable balance registry, OpenRouter credits/key limit client,
and llm-connect balance with one-shot --provider that does not change
library defaults.
This commit is contained in:
tegwick 2026-08-03 23:49:01 +02:00
parent c0d5c4ad08
commit ed7c632155
11 changed files with 783 additions and 14 deletions

View file

@ -21,8 +21,18 @@ from llm_connect.embedding_adapter import EmbeddingAdapter
from llm_connect.embedding_cache import EmbeddingCache
from llm_connect.embedding_factory import create_embedding_adapter
from llm_connect.embedding_openai import OpenAICompatibleEmbeddingAdapter
from llm_connect.balance import (
AccountBalance,
BalanceClientRegistry,
OpenRouterBalanceClient,
ProviderBalanceClient,
format_balance_human,
get_account_balance,
resolve_balance_provider,
)
from llm_connect.exceptions import (
LLMAPIError,
LLMBalanceUnsupportedError,
LLMBudgetExceededError,
LLMConfigurationError,
LLMError,
@ -103,6 +113,14 @@ __all__ = [
"LLMTimeoutError",
"LLMSubprocessError",
"LLMBudgetExceededError",
"LLMBalanceUnsupportedError",
"AccountBalance",
"BalanceClientRegistry",
"OpenRouterBalanceClient",
"ProviderBalanceClient",
"format_balance_human",
"get_account_balance",
"resolve_balance_provider",
"EmbeddingAdapter",
"OpenAICompatibleEmbeddingAdapter",
"EmbeddingCache",

View file

@ -38,7 +38,36 @@ def post_json(
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
return _read_json_response(url, req, timeout=timeout)
def get_json(
url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = 60,
) -> Dict[str, Any]:
"""GET *url* and return the parsed JSON response body.
Raises:
LLMRateLimitError: on HTTP 429
LLMAPIError: on other non-2xx responses
LLMTimeoutError: on socket / read timeout
"""
record_provider_request(url=url, payload=None, headers=headers or {})
req = urllib.request.Request(
url,
headers={**(headers or {})},
method="GET",
)
return _read_json_response(url, req, timeout=timeout)
def _read_json_response(
url: str,
req: urllib.request.Request,
*,
timeout: int,
) -> Dict[str, Any]:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode()

357
llm_connect/balance.py Normal file
View file

@ -0,0 +1,357 @@
"""Provider-scoped account balance / prepaid remaining.
Balance lookup is always bound to an explicit or default **backend**
(provider). Selecting a provider for a balance query must never mutate
library defaults (model or provider).
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
from llm_connect._http import get_json
from llm_connect.config import LLMConfig, find_project_root, resolve_api_key
from llm_connect.exceptions import (
LLMAPIError,
LLMBalanceUnsupportedError,
LLMConfigurationError,
)
from llm_connect.fx import FxRate, resolve_fx_rate
@dataclass(frozen=True)
class AccountBalance:
"""Normalised prepaid / key-limit snapshot for one backend."""
provider: str
currency: str
source: str
credits_total: float | None = None
credits_used: float | None = None
credits_remaining: float | None = None
limit: float | None = None
limit_remaining: float | None = None
limit_reset: str | None = None
usage: float | None = None
credits_remaining_eur: float | None = None
limit_remaining_eur: float | None = None
fx_source: str | None = None
raw: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
provider = str(self.provider or "").strip()
currency = str(self.currency or "").strip().upper()
source = str(self.source or "").strip()
if not provider:
raise ValueError("provider must be a non-empty string")
if not currency:
raise ValueError("currency must be a non-empty string")
if not source:
raise ValueError("source must be a non-empty string")
object.__setattr__(self, "provider", provider)
object.__setattr__(self, "currency", currency)
object.__setattr__(self, "source", source)
object.__setattr__(self, "raw", dict(self.raw or {}))
def to_dict(self) -> dict[str, Any]:
"""JSON-serialisable view (includes raw for automation)."""
return {
"provider": self.provider,
"currency": self.currency,
"source": self.source,
"credits_total": self.credits_total,
"credits_used": self.credits_used,
"credits_remaining": self.credits_remaining,
"limit": self.limit,
"limit_remaining": self.limit_remaining,
"limit_reset": self.limit_reset,
"usage": self.usage,
"credits_remaining_eur": self.credits_remaining_eur,
"limit_remaining_eur": self.limit_remaining_eur,
"fx_source": self.fx_source,
"raw": dict(self.raw),
}
@runtime_checkable
class ProviderBalanceClient(Protocol):
"""Backend that can report account / key prepaid remaining."""
provider_id: str
def get_balance(self, *, fx: FxRate | float | None = None) -> AccountBalance:
"""Fetch a live balance snapshot for this provider."""
...
BalanceClientFactory = Callable[[], ProviderBalanceClient]
def resolve_balance_provider(explicit: str | None = None) -> str:
"""Resolve which backend to query for balance.
1. Non-empty *explicit* provider id (one-shot override)
2. Else :attr:`LLMConfig.provider` default (today ``openrouter``)
Read-only: never mutates config, env, or module defaults.
"""
if explicit is not None and str(explicit).strip():
return str(explicit).strip().lower()
return str(LLMConfig().provider).strip().lower()
class BalanceClientRegistry:
"""Map provider id → balance client factory."""
def __init__(
self,
factories: Mapping[str, BalanceClientFactory] | None = None,
) -> None:
self._factories: dict[str, BalanceClientFactory] = {
str(key).strip().lower(): factory
for key, factory in (factories or {}).items()
}
def register(self, provider_id: str, factory: BalanceClientFactory) -> None:
"""Register or replace a factory for *provider_id*."""
key = str(provider_id).strip().lower()
if not key:
raise ValueError("provider_id must be a non-empty string")
self._factories[key] = factory
def supported(self) -> list[str]:
"""Return sorted provider ids that implement balance."""
return sorted(self._factories)
def create(self, provider_id: str) -> ProviderBalanceClient:
"""Instantiate a client for *provider_id* or raise unsupported."""
key = str(provider_id).strip().lower()
factory = self._factories.get(key)
if factory is None:
supported = self.supported()
raise LLMBalanceUnsupportedError(
f"Account balance is not supported for provider {key!r}. "
f"Supported: {', '.join(supported) if supported else '(none)'}",
provider=key,
supported=supported,
)
return factory()
@classmethod
def default(cls) -> "BalanceClientRegistry":
"""Built-in registry (OpenRouter first; more backends later)."""
return cls(
{
"openrouter": lambda: OpenRouterBalanceClient(),
}
)
def get_account_balance(
provider: str | None = None,
*,
registry: BalanceClientRegistry | None = None,
fx: FxRate | float | None = None,
) -> AccountBalance:
"""Fetch balance for *provider* or the current default backend.
*provider* is a one-shot selector and does not change library defaults.
"""
resolved = resolve_balance_provider(provider)
clients = registry or BalanceClientRegistry.default()
return clients.create(resolved).get_balance(fx=fx)
class OpenRouterBalanceClient:
"""OpenRouter prepaid credits + API-key limit remaining."""
provider_id = "openrouter"
def __init__(
self,
api_key: str | None = None,
api_base: str = "https://openrouter.ai/api/v1",
*,
timeout: int = 30,
get_json_fn: Callable[..., dict[str, Any]] | None = None,
) -> None:
self._api_base = api_base.rstrip("/")
self._timeout = timeout
self._get_json = get_json_fn or get_json
root = find_project_root()
key_file_paths = [root / "apikey-openrouter.txt"] if root else []
self._api_key = resolve_api_key(
explicit=api_key,
env_var="OPENROUTER_API_KEY",
key_file_paths=key_file_paths,
)
def get_balance(self, *, fx: FxRate | float | None = None) -> AccountBalance:
if not self._api_key:
raise LLMConfigurationError(
"OpenRouter API key not found for balance lookup "
"(set OPENROUTER_API_KEY or apikey-openrouter.txt)",
context={"provider": self.provider_id},
)
headers = {"Authorization": f"Bearer {self._api_key}"}
credits_payload: dict[str, Any] | None = None
key_payload: dict[str, Any] | None = None
errors: list[str] = []
sources: list[str] = []
try:
credits_payload = self._get_json(
f"{self._api_base}/credits",
headers=headers,
timeout=self._timeout,
)
sources.append("credits")
except LLMAPIError as exc:
errors.append(f"credits: {exc}")
try:
key_payload = self._get_json(
f"{self._api_base}/auth/key",
headers=headers,
timeout=self._timeout,
)
sources.append("auth/key")
except LLMAPIError as exc:
errors.append(f"auth/key: {exc}")
if credits_payload is None and key_payload is None:
raise LLMAPIError(
"OpenRouter balance lookup failed for both /credits and /auth/key: "
+ "; ".join(errors),
context={"provider": self.provider_id, "errors": errors},
)
credits_total: float | None = None
credits_used: float | None = None
credits_remaining: float | None = None
limit: float | None = None
limit_remaining: float | None = None
limit_reset: str | None = None
usage: float | None = None
if credits_payload is not None:
data = _unwrap_data(credits_payload)
credits_total = _optional_float(data.get("total_credits"))
credits_used = _optional_float(data.get("total_usage"))
if credits_total is not None and credits_used is not None:
credits_remaining = credits_total - credits_used
if key_payload is not None:
data = _unwrap_data(key_payload)
limit = _optional_float(data.get("limit"))
limit_remaining = _optional_float(data.get("limit_remaining"))
usage = _optional_float(data.get("usage"))
raw_reset = data.get("limit_reset")
if raw_reset is not None and str(raw_reset).strip():
limit_reset = str(raw_reset).strip()
fx_rate = resolve_fx_rate(fx)
credits_remaining_eur = None
limit_remaining_eur = None
fx_source = None
if fx_rate is not None:
fx_source = fx_rate.source
if credits_remaining is not None:
credits_remaining_eur = fx_rate.usd_to_eur(credits_remaining)
if limit_remaining is not None:
limit_remaining_eur = fx_rate.usd_to_eur(limit_remaining)
source_label = "openrouter:" + "+".join(
f"/api/v1/{part}" for part in sources
)
if errors:
source_label += f" (partial: {'; '.join(errors)})"
return AccountBalance(
provider=self.provider_id,
currency="USD",
source=source_label,
credits_total=credits_total,
credits_used=credits_used,
credits_remaining=credits_remaining,
limit=limit,
limit_remaining=limit_remaining,
limit_reset=limit_reset,
usage=usage,
credits_remaining_eur=credits_remaining_eur,
limit_remaining_eur=limit_remaining_eur,
fx_source=fx_source,
raw={
"credits": credits_payload,
"auth_key": key_payload,
"errors": errors,
},
)
def format_balance_human(balance: AccountBalance) -> str:
"""Multi-line human-readable balance report."""
lines = [f"provider: {balance.provider}"]
if balance.credits_remaining is not None:
eur = _fmt_eur(balance.credits_remaining_eur)
lines.append(
f"account credits remaining: {_fmt_money(balance.credits_remaining)} "
f"{balance.currency}{eur}"
)
if balance.credits_total is not None and balance.credits_used is not None:
lines.append(
f" (total={_fmt_money(balance.credits_total)} "
f"used={_fmt_money(balance.credits_used)})"
)
else:
lines.append("account credits remaining: unavailable")
if balance.limit_remaining is not None:
eur = _fmt_eur(balance.limit_remaining_eur)
extra = []
if balance.limit is not None:
extra.append(f"limit={_fmt_money(balance.limit)}")
if balance.limit_reset:
extra.append(f"reset={balance.limit_reset}")
suffix = f" [{', '.join(extra)}]" if extra else ""
lines.append(
f"key limit remaining: {_fmt_money(balance.limit_remaining)} "
f"{balance.currency}{eur}{suffix}"
)
else:
lines.append("key limit remaining: unavailable")
if balance.fx_source:
lines.append(f"fx: {balance.fx_source}")
lines.append(f"source: {balance.source}")
return "\n".join(lines)
def _unwrap_data(payload: Mapping[str, Any]) -> Mapping[str, Any]:
data = payload.get("data", payload)
if isinstance(data, Mapping):
return data
return {}
def _optional_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _fmt_money(value: float) -> str:
return f"{value:.6f}".rstrip("0").rstrip(".") if value != 0 else "0"
def _fmt_eur(value: float | None) -> str:
if value is None:
return ""
return f" (~{_fmt_money(value)} EUR)"

View file

@ -10,7 +10,12 @@ 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
@ -106,6 +111,30 @@ def _build_parser() -> argparse.ArgumentParser:
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
@ -340,6 +369,27 @@ 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: {

View file

@ -90,6 +90,30 @@ class LLMBudgetExceededError(LLMError):
self.requested = requested
class LLMBalanceUnsupportedError(LLMConfigurationError):
"""Account balance is not supported for the selected provider.
Attributes:
provider: Provider id that was requested.
supported: Provider ids that currently implement balance.
"""
def __init__(
self,
message: str,
provider: str = "",
supported: Optional[list[str]] = None,
cause: Optional[Exception] = None,
context: Optional[Dict[str, Any]] = None,
):
supported_list = list(supported or [])
if context is None:
context = {"provider": provider, "supported": supported_list}
super().__init__(message, cause=cause, context=context)
self.provider = provider
self.supported = supported_list
class LLMSubprocessError(LLMError):
"""Claude Code CLI subprocess failed.