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.
This commit is contained in:
parent
c0d5c4ad08
commit
ed7c632155
11 changed files with 783 additions and 14 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
357
llm_connect/balance.py
Normal 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)"
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue