* feat: v2.1.0 — fal platform utilities (async fan-out, cost tools, recovery) Six new nodes under FAL/Platform, built on fal's queue/request-id/pricing primitives: - Fal Submit + Fal Collect: queue jobs without blocking and collect results later — N generations run in parallel inside one graph - Fal Result by Request ID: re-fetch any past generation from fal's queue without re-generating or re-billing - Fal Cost Estimator: per-run cost report parsed from registry pricing before anything is queued - Fal Session Costs: in-memory ledger of every fal call this session (endpoint, duration, request id, est. cost) with running total - Fal Save Media from URL: persist expiring CDN results into the ComfyUI output directory Core: utils/pricing.py (pricing-text parser + registry lookup), utils/ledger.py (thread-safe session ledger), api.py gains submit_only, result_from_request_id, request-id capture via on_enqueue, and automatic ledger recording; any_endpoint helpers extracted for reuse. 16 new tests (61 total). * feat: v2.2.0 — persistent result cache, spend guard, URL passthrough Three fal-platform features on top of the 2.1 platform pack: - Persistent result cache (sqlite, [cache] config): identical fal calls are served free from disk across ComfyUI restarts; uploads are deduplicated by content hash so identical inputs upload once. Bypass per node via force_rerun. Cache hits are never blocked by the spend guard and never recorded as spend. - Spend Guard + billing ([spend_guard] config): preflight refuses to submit once the session's estimated spend reaches session_budget_usd or the account balance (fal Billing API) falls below min_balance_usd. New Fal Account Balance node; ledger reconciliation against the aggregated usage API (fal exposes no per-request billed amounts). - fal-CDN URL passthrough: every dynamic node's media inputs gain optional *_direct_url twins (URL sent directly, no download/re-upload; 1,405 twins across 992 nodes) and image-kind nodes output image_urls alongside IMAGE — fal→fal chains with zero local I/O. Review fixes: path traversal blocked in FalSaveMediaURL (confined to the output dir, atomic O_EXCL filename claim), FalAnyEndpoint force_rerun now bypasses the cache, cache lookup ordered before spend-guard preflight, result recovery no longer counts as new session spend. 14 new tests (76 total).
96 lines
3.1 KiB
Python
96 lines
3.1 KiB
Python
"""Billing nodes: account balance reporting with spend-guard visibility."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from .fal_utils import BillingUtils, SpendGuard, logger
|
|
|
|
_CATEGORY = "FAL/Platform"
|
|
|
|
_UNAVAILABLE_HINT = (
|
|
"Hint: the balance API needs an API key with billing access "
|
|
"(Authorization: Key ...); check your key at https://fal.ai/dashboard/keys."
|
|
)
|
|
|
|
|
|
def _guard_line(settings: dict[str, float]) -> str:
|
|
"""One-line summary of the active spend-guard configuration."""
|
|
budget = settings.get("session_budget_usd") or 0.0
|
|
floor = settings.get("min_balance_usd") or 0.0
|
|
if budget <= 0 and floor <= 0:
|
|
return (
|
|
"Spend guard: disabled (set [spend_guard] session_budget_usd / "
|
|
"min_balance_usd in config.ini to enable)"
|
|
)
|
|
parts = []
|
|
if budget > 0:
|
|
parts.append(f"session budget ${budget:.2f}")
|
|
if floor > 0:
|
|
parts.append(f"min balance ${floor:.2f}")
|
|
return f"Spend guard: {', '.join(parts)}"
|
|
|
|
|
|
def _build_report(balance: float | None, settings: dict[str, float]) -> str:
|
|
"""Human-readable balance report. Never raises."""
|
|
if balance is not None:
|
|
lines = [f"fal account balance: ${balance:,.2f}"]
|
|
else:
|
|
lines = ["fal account balance: unavailable", _UNAVAILABLE_HINT]
|
|
return "\n".join([*lines, _guard_line(settings)])
|
|
|
|
|
|
class FalBalance:
|
|
"""Report the fal.ai account credit balance and active spend-guard limits."""
|
|
|
|
RETURN_TYPES = ("STRING", "FLOAT")
|
|
RETURN_NAMES = ("report", "balance_usd")
|
|
FUNCTION = "check"
|
|
CATEGORY = _CATEGORY
|
|
OUTPUT_NODE = True
|
|
DESCRIPTION = (
|
|
"Fetch your fal.ai account credit balance and show the active "
|
|
"spend-guard settings. Never fails: balance_usd is -1.0 when the "
|
|
"balance API is unavailable."
|
|
)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> dict[str, Any]:
|
|
return {
|
|
"required": {},
|
|
"optional": {
|
|
"force_refresh": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": "Bypass the 60s balance cache and query fal again",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs: Any) -> Any:
|
|
# The balance changes outside the graph; always re-run.
|
|
return float("nan")
|
|
|
|
def check(self, force_refresh: bool = False) -> tuple[str, float]:
|
|
try:
|
|
balance = BillingUtils.get_balance(force=bool(force_refresh))
|
|
settings = SpendGuard.settings()
|
|
report = _build_report(balance, settings)
|
|
except Exception as err: # This node must never fail the graph.
|
|
logger.warning("FalBalance: could not build balance report: %s", err)
|
|
balance = None
|
|
report = f"fal account balance: unavailable ({err})\n{_UNAVAILABLE_HINT}"
|
|
return (report, float(balance) if balance is not None else -1.0)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"FalBalance_fal": FalBalance,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"FalBalance_fal": "Fal Account Balance (fal)",
|
|
}
|