feat(update): track buzz prices and alert below a threshold

CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
This commit is contained in:
Will Miao
2026-10-04 08:53:24 +08:00
parent 5f4054265d
commit ad2402724b
31 changed files with 2829 additions and 33 deletions
+53
View File
@@ -183,6 +183,17 @@ class ModelMetadataProvider(ABC):
"""
return None
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
"""Fetch per-version buzz prices for a model, when the provider has them.
CivitAI publishes prices only inside its public model page payload;
providers that cannot read it (CivArchive, SQLite, OpenModelDB) keep the
default of None, which callers treat as "no price information".
"""
return None
class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata"""
@@ -222,6 +233,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
) -> Optional[Dict[str, Any]]:
return await self.client.get_version_file_mini(version_id, file_id)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
return await self.client.get_model_prices(model_id)
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive API for metadata"""
@@ -783,6 +799,34 @@ class FallbackMetadataProvider(ModelMetadataProvider):
def _iter_providers(self):
return zip(self.providers, self._provider_labels)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result = await self._call_with_rate_limit(
label,
provider.get_model_prices,
model_id,
)
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_prices: %s", label, e)
continue
return None
def excluding(self, labels: "frozenset[str] | set[str]") -> "FallbackMetadataProvider":
"""Return a copy of this chain without the providers named in *labels*.
@@ -907,6 +951,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
file_id,
)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_model_prices,
model_id,
)
class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers"""