Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/skills/pixellab-cli-assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ recovering a stopped run, and the free local tooling every category uses.

## Money

- Free or cheap, reach for them freely: `balance`, `ledger`, `character list|show|sheet`,
- Free or cheap, reach for them freely: `balance` (PixelLab's generations and
credits, and fal's credits where a fal key is configured), `ledger`,
`character list|show|sheet`,
`object list`, `recipe list`, every `image` command, every `export` command,
`config show|path`.
- **Pro Tools, twenty to forty generations a call:** `object new`, `ui new`, `inpaint`,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ names the route, the parameters, the seed and the identifiers PixelLab assigned.
ledger records every call, before it is made and again when it resolves.

```bash
pixellab-cli balance # what is left to spend
pixellab-cli balance # what is left to spend, on both providers
pixellab-cli ledger # what has been spent, by route, and what never resolved
```

Expand Down
1 change: 1 addition & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,4 @@ over this file answers with the example above as well as with the notes. -->
- n-0059 2026-09-20 #gotcha @tests/test_skill.py — the skill tests walk a hand-written CATEGORY_SKILLS tuple, so a packaged skill missing from it is tested by nothing; TestNothingShipsUntested binds it to harness.packaged_skills
- n-0060 2026-09-20 #gotcha @src/pixellab_cli/catalog.py — frame_default lives in catalog.py and reads the route's own frame_count, because every animating command multiplies it by the directions to estimate a call
- n-0061 2026-09-20 #gotcha @src/pixellab_cli/subject.py — a state can be made from another state, so the subject record walks the source chain to the root character; a pose filed under nothing is a guardrail that stops seeing it
- n-0062 2026-09-20 #ceiling @src/pixellab_cli/fal.py — fal's balance comes from the undocumented rest.alpha.fal.ai/billing/user_balance because the documented v1/account/billing needs an admin-scope key; a failure prints fal: not reported
17 changes: 17 additions & 0 deletions docs/wiki/pages/fal-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,20 @@ fal bills per image on its own account, per quality tier, with no shared balance
`usage` object on the response. Costs are recorded in the ledger from the tool's own
route table rather than read back from the provider — which means a fal price change goes
unnoticed until someone updates the table, and the ledger says the figure is an estimate.

### What is left on the account

`pixellab-cli balance` reports fal's credits beside PixelLab's, where a fal key is
configured. Not from the documented route: `GET https://api.fal.ai/v1/account/billing?expand=credits`
answers `403 authorization_error` to an API-scope key, and billing is admin scope. The
one fal's own dashboard reads takes the key this tool already holds and answers with a
bare number of USD:

```
GET https://rest.alpha.fal.ai/billing/user_balance Authorization: Key <FAL_KEY>
→ 200 7.3747928
```

Undocumented, and the host says `alpha`, so it is a ceiling rather than a contract. A
failure there prints `fal: not reported` and never costs the PixelLab half of the
answer, which is the one that command has always given.
5 changes: 3 additions & 2 deletions docs/wiki/pages/pixellab-cost-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,6 @@ batches rather than asserting a limit it cannot read.

## fal is priced separately

fal bills per image and per quality tier, on its own account, with no shared balance.
See [[fal-platform]].
fal bills per image and per quality tier, on its own account, with no shared balance —
two accounts, two balances, and `pixellab-cli balance` reports both where fal is
configured. See [[fal-platform]].
29 changes: 29 additions & 0 deletions specs/provider-core/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,32 @@ One helper now guards both, and the poll loop calls it before every attempt: a
covers `submissions`, where the object route puts its identifiers, and the flat
`background_job_ids` a character animation returns, which had the same gap one layer
shallower.

## What is left to spend, on both providers

`balance` asked PixelLab and stopped there, which was the whole answer while PixelLab
was the only provider that could say. fal can say too, and the question a person asks
before a paid run is about the account they are about to spend from — so it answers for
whichever providers are configured (R5.4).

fal's documented route is `GET https://api.fal.ai/v1/account/billing?expand=credits`.
It answers `403 authorization_error` to the key this tool holds: that key is API scope,
and billing is admin scope. Minting a second, wider key so a free line can be printed is
a worse trade than not printing it. What the fal dashboard itself calls answers with the
same key this tool already has:

```
GET https://rest.alpha.fal.ai/billing/user_balance Authorization: Key <FAL_KEY>
→ 200 7.3747928
```

A bare number, in USD. It is undocumented and the host says `alpha`, so it is a ceiling
rather than a contract, and the code treats it as one: a failure there reports that fal
did not say and never turns `balance` into an error (R5.5). The PixelLab half is the
answer this command has always given, and it must not be lost because a second provider
was unreachable.

A provider with no credentials is left out entirely rather than reported as a failure.
Not configuring fal is the ordinary case — it is optional
(`adr:0010-fal-is-optional-and-pixellab-is-the-fallback`) — and "fal: not reported" for
an account that has no fal is noise that reads as a fault.
6 changes: 4 additions & 2 deletions specs/provider-core/requirements.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
autonomy: auto
ci: wait
branch: feat/an-object-animation-takes-more-directions
branch: feat/balance-says-both-providers
delivery: in-review
pr: 82
pr: 85
---

# Provider core — requirements
Expand Down Expand Up @@ -68,6 +68,8 @@ looks like have to be decided once.
- **R5.1** The provider core shall return, for every call, the decoded images, the identifiers the provider assigned, and the usage the provider reported.
- **R5.2** Where a provider reports no usage, the provider core shall return the tool's own estimate marked as an estimate, and shall report the cost as unknown where it has no estimate for that route.
- **R5.3** (ADDED) Where a provider reports how long a call took, the provider core shall return that alongside what it cost.
- **R5.4** (ADDED) The provider core shall report what is left to spend on every provider whose credentials are configured, and shall leave out a provider that has none rather than failing.
- **R5.5** (ADDED) If a provider cannot say what is left, then the provider core shall report that for that provider and shall still report the others.

## Out of scope

Expand Down
8 changes: 8 additions & 0 deletions specs/provider-core/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,11 @@
- [x] 8.3 (Unit) Guard the job id a response chose, not only a typed one — R3.6
_Reason it reached the poll path unchecked_


## 9 · What is left to spend, on both providers
- [x] 9.1 (Unit) Ask fal what is left, with the key this tool already holds — R5.4
_Reason balance asked only PixelLab_
- [x] 9.2 (Unit) Report both providers, and a silent one as unreported — R5.4, R5.5
_Depends 9.1_
_Reason balance asked only PixelLab_

14 changes: 13 additions & 1 deletion src/pixellab_cli/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

@app.command()
def balance(context: typer.Context) -> None:
"""Subscription generations remaining, and USD credits."""
"""What is left to spend, on every provider that is configured. Free."""
app_context: AppContext = context.obj
try:
result = app_context.pixellab().call("balance")
Expand All @@ -46,6 +46,18 @@ def balance(context: typer.Context) -> None:
if not lines:
lines.append("PixelLab reported no balance")

# fal is optional, so an account without it is not missing anything: no line at
# all, rather than one that reads as a fault. The key is read from the
# credentials rather than by attempting a call, which is what tells "not
# configured" from "configured and did not answer".
if app_context.credentials.fal_key:
held = app_context.fal().balance()
lines.append(f"fal credits: ${held:.2f}" if held is not None else "fal: not reported")
# Under the PixelLab payload rather than merged into it: the keys at the top
# of this document are PixelLab's, and something reading them by name should
# not start finding another provider's among them.
payload = {**payload, "fal": {"credits_usd": held}}

output.emit(payload, lines, as_json=app_context.as_json)


Expand Down
44 changes: 44 additions & 0 deletions src/pixellab_cli/fal.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import base64
import math
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -56,6 +57,13 @@ def quality_to_send(value: str | None) -> str:

DOWNLOAD_TIMEOUT = 120.0

# What fal's own dashboard reads for the figure it shows. See `FalClient.balance`
# for why the documented billing route is not the one called.
FAL_BALANCE_URL = "https://rest.alpha.fal.ai/billing/user_balance"
# Short on purpose: this is a line beside an answer the command already has, and
# nobody should wait on it.
BALANCE_TIMEOUT = 15.0

# Shared by all four GPT Image 2.5 endpoints; the two `edit` ones add image_urls
# and mask_url. See docs/wiki/pages/gpt-image-25.md.
_COMMON = (
Expand Down Expand Up @@ -202,6 +210,42 @@ def generate(self, name: str, **arguments: Any) -> FalResult:
raw=payload,
)

def balance(self) -> float | None:
"""What fal says is left on the account, in USD, or None if it will not say.

The documented route is `GET /v1/account/billing?expand=credits` on
`api.fal.ai`, and it answers `403 authorization_error` to the key this tool
holds: that key is API scope and billing is admin scope. Asking someone to
mint a second, wider key so a free line can be printed is the worse trade.
What fal's own dashboard calls takes the key we already have and answers with
a bare number.

Undocumented, and the host says `alpha`, so this is a ceiling rather than a
contract — `None` on anything unexpected, and the caller says fal did not
report rather than failing. The balance of the other provider is the answer
this command has always given and must not be lost to this one.
"""
key = self._credentials.fal_key
if not key:
return None
try:
if self._http is not None:
response = self._http.get(
FAL_BALANCE_URL,
headers={"Authorization": f"Key {key}"},
timeout=BALANCE_TIMEOUT,
)
else:
with httpx.Client(timeout=BALANCE_TIMEOUT) as client:
response = client.get(FAL_BALANCE_URL, headers={"Authorization": f"Key {key}"})
response.raise_for_status()
held = float(response.text.strip())
except (httpx.HTTPError, ValueError):
return None
# `inf` and `nan` parse, and a balance printed as `$nan` is worse than one
# not printed: it reads as a number the account actually holds.
return held if math.isfinite(held) else None

def _seconds(self, application: str, request_id: str | None, key: str) -> float | None:
"""How long fal says the finished job took, or None if it will not say.

Expand Down
4 changes: 3 additions & 1 deletion src/pixellab_cli/skill/pixellab-cli-assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ recovering a stopped run, and the free local tooling every category uses.

## Money

- Free or cheap, reach for them freely: `balance`, `ledger`, `character list|show|sheet`,
- Free or cheap, reach for them freely: `balance` (PixelLab's generations and
credits, and fal's credits where a fal key is configured), `ledger`,
`character list|show|sheet`,
`object list`, `recipe list`, every `image` command, every `export` command,
`config show|path`.
- **Pro Tools, twenty to forty generations a call:** `object new`, `ui new`, `inpaint`,
Expand Down
104 changes: 103 additions & 1 deletion tests/test_commands_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typer.testing import CliRunner

from pixellab_cli.cli import app
from pixellab_cli.config import PIXELLAB_BASE_URL, PIXELLAB_SECRET_VAR
from pixellab_cli.config import FAL_KEY_VAR, PIXELLAB_BASE_URL, PIXELLAB_SECRET_VAR
from pixellab_cli.fal import FAL_BALANCE_URL
from pixellab_cli.ledger import Cost, Ledger

runner = CliRunner()
Expand Down Expand Up @@ -151,3 +152,104 @@ def test_version_prints_a_version(self):

def test_bare_invocation_shows_help(self):
assert "Usage" in runner.invoke(app, []).stdout


class TestTheBalanceOfBothProviders:
"""R5.4 and R5.5. The question before a paid run is about the account being spent
from, and there are two of them. fal is optional, so an account without it is not
missing anything."""

def a_pixellab_balance(self):
respx.get(f"{PIXELLAB_BASE_URL}/balance").respond(
json={"credits": {"usd": 5.25}, "subscription": {"generations": 120, "total": 500}}
)

@respx.mock
def test_fal_is_reported_when_it_is_configured(self, tmp_path, monkeypatch):
monkeypatch.setenv(FAL_KEY_VAR, "fal-test-key")
self.a_pixellab_balance()
respx.get(FAL_BALANCE_URL).respond(text="7.3747928")

result = invoke(["balance"], tmp_path, monkeypatch)

assert result.exit_code == 0
assert "fal credits: $7.37" in result.stdout
assert "120 of 500" in result.stdout

@respx.mock
def test_the_key_goes_in_the_header_fal_expects(self, tmp_path, monkeypatch):
monkeypatch.setenv(FAL_KEY_VAR, "fal-test-key")
self.a_pixellab_balance()
asked = respx.get(FAL_BALANCE_URL).respond(text="7.37")

invoke(["balance"], tmp_path, monkeypatch)

assert asked.calls.last.request.headers["authorization"] == "Key fal-test-key"

@respx.mock
def test_an_account_with_no_fal_key_says_nothing_about_fal(self, tmp_path, monkeypatch):
"""Not configuring fal is the ordinary case; a line about it would read as a
fault where there is none."""
monkeypatch.delenv(FAL_KEY_VAR, raising=False)
self.a_pixellab_balance()
asked = respx.get(FAL_BALANCE_URL)

result = invoke(["balance"], tmp_path, monkeypatch)

assert "fal" not in result.stdout.lower()
assert not asked.called

@respx.mock
def test_a_fal_that_will_not_answer_does_not_lose_the_other_half(self, tmp_path, monkeypatch):
"""The route is undocumented and says `alpha`; PixelLab's answer is the one
this command has always given."""
monkeypatch.setenv(FAL_KEY_VAR, "fal-test-key")
self.a_pixellab_balance()
respx.get(FAL_BALANCE_URL).respond(403, json={"error": "nope"})

result = invoke(["balance"], tmp_path, monkeypatch)

assert result.exit_code == 0
assert "fal: not reported" in result.stdout
assert "120 of 500" in result.stdout

@respx.mock
def test_an_answer_that_is_not_a_number_is_not_reported(self, tmp_path, monkeypatch):
monkeypatch.setenv(FAL_KEY_VAR, "fal-test-key")
self.a_pixellab_balance()
respx.get(FAL_BALANCE_URL).respond(text="<html>maintenance</html>")

result = invoke(["balance"], tmp_path, monkeypatch)

assert result.exit_code == 0
assert "fal: not reported" in result.stdout

@respx.mock
def test_the_json_keeps_pixellab_at_the_top_and_fal_beside_it(self, tmp_path, monkeypatch):
"""Something reading the PixelLab keys by name must not start finding
another provider's among them."""
monkeypatch.setenv(FAL_KEY_VAR, "fal-test-key")
self.a_pixellab_balance()
respx.get(FAL_BALANCE_URL).respond(text="7.37")

result = invoke(["--json", "balance"], tmp_path, monkeypatch)

payload = json.loads(result.stdout)
assert payload["subscription"]["generations"] == 120
assert payload["fal"] == {"credits_usd": 7.37}

@respx.mock
def test_the_json_says_fal_did_not_answer_rather_than_leaving_it_out(
self, tmp_path, monkeypatch
):
"""Configured and silent is not the same as absent, and a parser reading
this has to be able to tell them apart."""
monkeypatch.setenv(FAL_KEY_VAR, "fal-test-key")
self.a_pixellab_balance()
respx.get(FAL_BALANCE_URL).respond(503, text="down")

result = invoke(["--json", "balance"], tmp_path, monkeypatch)

payload = json.loads(result.stdout)
assert payload["fal"] == {"credits_usd": None}
assert payload["subscription"]["generations"] == 120
28 changes: 27 additions & 1 deletion tests/test_fal.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from pixellab_cli.config import Credentials
from pixellab_cli.errors import ConfigurationError, ProviderError, ValidationError
from pixellab_cli.fal import ALIASES, MODELS, FalClient, model
from pixellab_cli.fal import ALIASES, FAL_BALANCE_URL, MODELS, FalClient, model

CREDENTIALS = Credentials(fal_key="fal-test-key")

Expand Down Expand Up @@ -357,3 +357,29 @@ def test_no_request_id_means_nothing_to_ask_about(self):

assert client.generate("concept", prompt="a castle").seconds is None
assert asked == []


class TestABalanceThatIsNotANumber:
"""`float` accepts `inf` and `nan`. A balance printed as `$nan` reads as a figure
the account holds, which is worse than not printing one."""

@respx.mock
def test_an_infinite_balance_is_not_reported(self):
respx.get(FAL_BALANCE_URL).respond(text="inf")

assert FalClient(Credentials(fal_key="k")).balance() is None

@respx.mock
def test_a_nan_balance_is_not_reported(self):
respx.get(FAL_BALANCE_URL).respond(text="nan")

assert FalClient(Credentials(fal_key="k")).balance() is None

@respx.mock
def test_an_ordinary_balance_comes_through(self):
respx.get(FAL_BALANCE_URL).respond(text="7.3747928")

assert FalClient(Credentials(fal_key="k")).balance() == 7.3747928

def test_no_key_asks_nothing(self):
assert FalClient(Credentials()).balance() is None
Loading