llm-connect/workplans/LLM-WP-0008-provider-account-balance-cli.md
codex 8bb446c1fa
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
fix(workplans): adopt ADR-007 derived identifiers for unregistered records
These workplans exist only in the retired local hub. Their random pre-ADR-007
identifiers are refused by C-06 as stale references, so they cannot be
registered. Deriving from the canonical record id takes no identity from
anything: central does not hold them and the old ids die with the cache.

Records central already holds were deliberately left untouched.

Refs CUST-WP-0068-T06

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 20:13:58 +02:00

298 lines
11 KiB
Markdown
Raw 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.

---
id: LLM-WP-0008
type: workplan
title: "Provider-scoped account balance CLI"
domain: agents
repo: llm-connect
status: finished
owner: codex
topic_slug: provider-account-balance-cli
planning_priority: high
planning_order: 8
created: "2026-08-03"
updated: "2026-08-03"
depends_on_workplans:
- LLM-WP-0007
related_workplans: []
state_hub_workstream_id: "d43fd87d-27de-5e00-b362-9cb96ca5ffa5"
---
# LLM-WP-0008 — Provider-scoped account balance CLI
**status:** finished
**owner:** codex
## Purpose
Add a CLI (and library) surface that reports **remaining prepaid / account
budget** for the **selected inference backend**, without treating OpenRouter
as the only possible source of truth and **without changing the library or
operator default** provider/model.
Constraints from operator (2026-08-03):
1. Balance depends on the **backend** (provider). A future inference service
must not silently be asked for “the OpenRouter budget”.
2. The command must accept an explicit **backend selector for that invocation
only** — it must **not** switch or persist the default basemodel/provider.
3. If the backend is **not** specified, report balance for the **currently
configured default backend** (today: `openrouter` via `LLMConfig.provider`
/ factory default).
## Demand signal
After LLM-WP-0007, local estimated spend is visible (`spend week`), but the
operator still cannot see **remaining OpenRouter prepaid credits / key limit**
from `llm-connect`. That gap is backend-specific; the design must stay
pluggable.
## Current repo state (as of 2026-08-03)
| Area | State |
|---|---|
| CLI | `rates`, `classes`, `run`, `cost`, `spend`**no** balance/credits command |
| Default provider | `openrouter` (`LLMConfig.provider`, `create_adapter` default) |
| Default model | `moonshotai/kimi-k3` (OpenRouter) — **must not** be mutated by balance CLI |
| OpenRouter live APIs (checked) | `GET /api/v1/auth/key``limit`, `limit_remaining`, `usage`, `limit_reset`, …; `GET /api/v1/credits``total_credits`, `total_usage` |
| Other providers | Gemini / OpenAI / Claude Code have **no** balance client yet |
OpenRouter semantics worth distinguishing in the UX:
| Signal | Source | Meaning |
|---|---|---|
| **Account credits remaining** | `total_credits - total_usage` from `/credits` | Prepaid balance on the OpenRouter account |
| **Key limit remaining** | `limit_remaining` from `/auth/key` | Remaining allowance on **this API key** (may be lower than account credits; may reset monthly) |
v1 should surface **both** when available, labelled clearly, with EUR display
optional via existing FX helpers (amounts from OpenRouter are USD).
## Architecture sketch
```
llm_connect/
balance.py # NEW: ProviderBalance protocol + registry + resolve_default_provider()
openrouter_balance.py # NEW: OpenRouterBalanceClient (auth/key + credits)
# later: openai_balance.py, etc. — only if a real API exists
cli.py # llm-connect balance [--provider …]
config.py # unchanged defaults; only *read* default provider
```
### Provider selection (critical)
```bash
# Uses default backend (today: openrouter). Does NOT change defaults.
llm-connect balance
# One-shot backend override for this command only — no config write, no
# mutation of LLMConfig defaults or OpenRouterAdapter._DEFAULT_MODEL.
llm-connect balance --provider openrouter
llm-connect balance --provider openai # may error: unsupported until implemented
```
Resolution order for **which backend to query**:
1. Explicit `--provider` / library `provider=` argument
2. Else `LLMConfig.provider` default (currently `"openrouter"`)
3. Never infer “whatever model string looks like” and never call OpenRouter
because “its the only client we have” when another provider was selected
Calling `balance` with `--provider X` **must not**:
- rewrite config files, env, or key files
- change `LLMConfig` class defaults or module-level `_DEFAULT_MODEL`
- affect subsequent `llm-connect run` without an explicit provider on that run
### Pluggable contract
```python
@dataclass(frozen=True)
class AccountBalance:
provider: str
currency: str # usually "USD" from upstream
# Prepaid / wallet style (optional)
credits_total: float | None = None
credits_used: float | None = None
credits_remaining: float | None = None
# Key/limit style (optional)
limit: float | None = None
limit_remaining: float | None = None
limit_reset: str | None = None # e.g. "monthly"
usage: float | None = None
# Display
credits_remaining_eur: float | None = None
limit_remaining_eur: float | None = None
fx_source: str | None = None
source: str # e.g. "openrouter:/api/v1/credits+auth/key"
raw: dict[str, Any] = field(default_factory=dict) # optional debug
class ProviderBalanceClient(Protocol):
provider_id: str
def get_balance(self) -> AccountBalance: ...
```
Registry maps `provider_id → client factory`. Unknown / unimplemented
providers raise a clear `LLMConfigurationError` (or dedicated
`LLMBalanceUnsupportedError`) listing which providers support balance.
### OpenRouter client (v1 implementation)
- Auth: same key resolution as adapters (`OPENROUTER_API_KEY` / key file)
- Fetch `/api/v1/credits` and `/api/v1/auth/key` (best-effort both; degrade
gracefully if one fails, but fail if both fail)
- `credits_remaining = total_credits - total_usage` when credits payload present
- Attach key `limit_remaining` / `limit` / `limit_reset` when key payload present
- Convert remaining amounts to EUR via existing `resolve_fx_rate` (optional,
same as cost estimates)
### CLI output
Human (default):
```
provider: openrouter
account credits remaining: 12.715255 USD (~11.70 EUR)
key limit remaining: 9.983798 USD (~9.19 EUR) [limit=10, reset=monthly]
source: openrouter:/api/v1/credits+auth/key
```
`--json` for automation. Never print full API keys; label redaction already
matches OpenRouters partial label if shown.
### Unsupported backends
| Provider | v1 behaviour |
|---|---|
| `openrouter` | Implemented |
| `openai`, `gemini`, `claude-code`, `mock` | Explicit unsupported error with exit ≠ 0 |
| unknown | Same as factory: unknown provider error |
Mock may optionally return a fixed balance for tests only if registered under
`mock` — prefer unit-testing OpenRouter client with `urllib` mocks instead of
shipping a fake production path.
## Scope guardrails
**In scope**
- Pluggable balance protocol + registry
- OpenRouter implementation using real APIs
- CLI `balance` with `--provider` one-shot override
- Default-provider resolution without mutating defaults
- EUR display via existing FX
- Tests (mocked HTTP); optional live smoke gated on key
- Docs (README + short contract)
**Out of scope**
- Changing default provider or default model
- Writing balance into the usage ledger (different concern)
- Billing reconciliation / invoices
- Implementing Gemini/OpenAI balance clients until demand + known API
- Interactive provider picker / TUI
- Caching balance across process invocations (always live fetch in v1)
## Tasks
```task
id: LLM-WP-0008-T01
status: done
priority: high
state_hub_task_id: "ba9af2d8-8887-53f3-98d8-6e0a25846125"
```
**Define `AccountBalance` + `ProviderBalanceClient` protocol and registry** in
`balance.py` (or equivalent). Include `resolve_balance_provider(explicit)` that
returns the default provider when `explicit` is `None`, without side effects.
Tests: default resolution, unknown provider, unsupported provider.
```task
id: LLM-WP-0008-T02
status: done
priority: high
state_hub_task_id: "4381bf6a-dc61-5b54-b524-158e054bb32f"
```
**OpenRouter balance client.** Call `/api/v1/credits` and `/api/v1/auth/key`
with existing key resolution and HTTP helpers. Map to `AccountBalance` with
clear field semantics. Unit tests with mocked responses (including partial
failure of one endpoint). Live smoke optional / manual.
```task
id: LLM-WP-0008-T03
status: done
priority: high
state_hub_task_id: "1cd9a7c1-32c8-5487-8bae-b045bf2c245b"
```
**CLI `llm-connect balance [--provider …] [--json] [--eur-per-usd …]`.**
Explicit `--provider` is per-invocation only. Omit → default backend.
Print tokens-free account/key remaining in USD and €. Exit non-zero for
unsupported backends. Tests via mocked client registration.
```task
id: LLM-WP-0008-T04
status: done
priority: medium
state_hub_task_id: "0326e771-906d-540d-87d2-86e99b7d1165"
```
**Package exports + contract docs + README.** Export balance types; document
that balance is backend-scoped and that `--provider` does not change defaults.
Note OpenRouter dual signals (account credits vs key limit).
```task
id: LLM-WP-0008-T05
status: done
priority: low
state_hub_task_id: "a602f04d-15dc-58e6-9b0f-fadf8f7edb62"
```
**Acceptance polish.** Confirm with a real key (operator or CI secret) that
`llm-connect balance` and `llm-connect balance --provider openrouter` match
within rounding of OpenRouter dashboard figures; document any known drift
(e.g. pending usage).
## Acceptance
- [x] `llm-connect balance` reports OpenRouter remaining budget when default
provider is `openrouter`, without changing any defaults
- [x] `llm-connect balance --provider openrouter` same result; still no default
mutation
- [x] `llm-connect balance --provider <unsupported>` fails clearly (not with
OpenRouter data)
- [x] Account credits remaining and key limit remaining are distinguishable in
output when both are available
- [x] EUR amounts shown when FX available
- [x] Adding a future provider is “register a balance client”, not hard-coding
OpenRouter into the CLI command body
- [x] Offline unit tests pass without network
## Risks / notes
- **Credits vs key limit:** operators often care about prepaid wallet
(`/credits`); key-level caps (`/auth/key`) can be lower. Showing only one
risks false “I still have money” / “Im broke” readings — show both.
- **Currency:** OpenRouter amounts are USD; EUR is display conversion only
(same philosophy as LLM-WP-0007).
- **Default provider today is openrouter:** that is correct for “unspecified
→ default backend”, but the implementation must not special-case
“unspecified always means OpenRouter” in the CLI handler — go through
`resolve_balance_provider(None)`.
- **No secret leakage:** never print full API keys; raw dumps only behind an
explicit future `--debug` if ever added (not in v1 human output).
## Implementation order
T01 → T02 → T03 → T04 → T05.
## Implementation notes (2026-08-03)
- Added `llm_connect.balance` with pluggable registry; OpenRouter client uses
`/api/v1/credits` + `/api/v1/auth/key` via new `get_json` HTTP helper.
- CLI: `llm-connect balance [--provider] [--json] [--eur-per-usd]`.
- Live smoke: account credits remaining ~12.72 USD and key limit remaining
~9.98 USD; unsupported `gemini` exits 2; defaults unchanged
(`openrouter` / `moonshotai/kimi-k3`).
- Unit suite: 237 passed.