diff --git a/.claude/skills/pixellab-cli-assets/SKILL.md b/.claude/skills/pixellab-cli-assets/SKILL.md index 9933c5c..3ccaef8 100644 --- a/.claude/skills/pixellab-cli-assets/SKILL.md +++ b/.claude/skills/pixellab-cli-assets/SKILL.md @@ -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`, diff --git a/README.md b/README.md index 0983b2f..878e230 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/notes.md b/docs/notes.md index a5190a0..80bb699 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -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 diff --git a/docs/wiki/pages/fal-platform.md b/docs/wiki/pages/fal-platform.md index b6a65bd..35cd4c9 100644 --- a/docs/wiki/pages/fal-platform.md +++ b/docs/wiki/pages/fal-platform.md @@ -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 +→ 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. diff --git a/docs/wiki/pages/pixellab-cost-model.md b/docs/wiki/pages/pixellab-cost-model.md index 7d183ef..d58936c 100644 --- a/docs/wiki/pages/pixellab-cost-model.md +++ b/docs/wiki/pages/pixellab-cost-model.md @@ -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]]. diff --git a/specs/provider-core/design.md b/specs/provider-core/design.md index e9f26ff..95ebe4c 100644 --- a/specs/provider-core/design.md +++ b/specs/provider-core/design.md @@ -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 +→ 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. diff --git a/specs/provider-core/requirements.md b/specs/provider-core/requirements.md index 5cafb84..c02aebb 100644 --- a/specs/provider-core/requirements.md +++ b/specs/provider-core/requirements.md @@ -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 @@ -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 diff --git a/specs/provider-core/tasks.md b/specs/provider-core/tasks.md index cc03238..ce06779 100644 --- a/specs/provider-core/tasks.md +++ b/specs/provider-core/tasks.md @@ -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_ + diff --git a/src/pixellab_cli/commands/account.py b/src/pixellab_cli/commands/account.py index 34a4484..f427149 100644 --- a/src/pixellab_cli/commands/account.py +++ b/src/pixellab_cli/commands/account.py @@ -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") @@ -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) diff --git a/src/pixellab_cli/fal.py b/src/pixellab_cli/fal.py index 08e3e3c..126432d 100644 --- a/src/pixellab_cli/fal.py +++ b/src/pixellab_cli/fal.py @@ -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 @@ -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 = ( @@ -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. diff --git a/src/pixellab_cli/skill/pixellab-cli-assets/SKILL.md b/src/pixellab_cli/skill/pixellab-cli-assets/SKILL.md index 9933c5c..3ccaef8 100644 --- a/src/pixellab_cli/skill/pixellab-cli-assets/SKILL.md +++ b/src/pixellab_cli/skill/pixellab-cli-assets/SKILL.md @@ -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`, diff --git a/tests/test_commands_account.py b/tests/test_commands_account.py index d6ea61a..ebd7386 100644 --- a/tests/test_commands_account.py +++ b/tests/test_commands_account.py @@ -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() @@ -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="maintenance") + + 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 diff --git a/tests/test_fal.py b/tests/test_fal.py index 2048ef1..2f14b2d 100644 --- a/tests/test_fal.py +++ b/tests/test_fal.py @@ -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") @@ -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