diff --git a/circuitforge_core/tiers/__init__.py b/circuitforge_core/tiers/__init__.py new file mode 100644 index 0000000..115acb8 --- /dev/null +++ b/circuitforge_core/tiers/__init__.py @@ -0,0 +1,3 @@ +from .tiers import can_use, tier_label, TIERS, BYOK_UNLOCKABLE, LOCAL_VISION_UNLOCKABLE + +__all__ = ["can_use", "tier_label", "TIERS", "BYOK_UNLOCKABLE", "LOCAL_VISION_UNLOCKABLE"] diff --git a/circuitforge_core/tiers/tiers.py b/circuitforge_core/tiers/tiers.py new file mode 100644 index 0000000..d243ad6 --- /dev/null +++ b/circuitforge_core/tiers/tiers.py @@ -0,0 +1,76 @@ +""" +Tier system for CircuitForge products. + +Tiers: free < paid < premium < ultra +Products register their own FEATURES dict and pass it to can_use(). + +BYOK_UNLOCKABLE: features that unlock when the user has any configured +LLM backend (local or API key). These are gated only because CF would +otherwise provide the compute. + +LOCAL_VISION_UNLOCKABLE: features that unlock when the user has a local +vision model configured (e.g. moondream2). Distinct from BYOK — a text +LLM key does NOT unlock vision features. +""" +from __future__ import annotations + +TIERS: list[str] = ["free", "paid", "premium", "ultra"] + +# Features that unlock when the user has any LLM backend configured. +# Each product extends this frozenset with its own BYOK-unlockable features. +BYOK_UNLOCKABLE: frozenset[str] = frozenset() + +# Features that unlock when the user has a local vision model configured. +LOCAL_VISION_UNLOCKABLE: frozenset[str] = frozenset() + + +def can_use( + feature: str, + tier: str, + has_byok: bool = False, + has_local_vision: bool = False, + _features: dict[str, str] | None = None, +) -> bool: + """ + Return True if the given tier (and optional unlocks) can access feature. + + Args: + feature: Feature key string. + tier: User's current tier ("free", "paid", "premium", "ultra"). + has_byok: True if user has a configured LLM backend. + has_local_vision: True if user has a local vision model configured. + _features: Feature→min_tier map. Products pass their own dict here. + If None, all features are free. + """ + features = _features or {} + if feature not in features: + return True + + if has_byok and feature in BYOK_UNLOCKABLE: + return True + + if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: + return True + + min_tier = features[feature] + try: + return TIERS.index(tier) >= TIERS.index(min_tier) + except ValueError: + return False + + +def tier_label( + feature: str, + has_byok: bool = False, + has_local_vision: bool = False, + _features: dict[str, str] | None = None, +) -> str: + """Return a human-readable label for the minimum tier needed for feature.""" + features = _features or {} + if feature not in features: + return "free" + if has_byok and feature in BYOK_UNLOCKABLE: + return "free (BYOK)" + if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: + return "free (local vision)" + return features[feature] diff --git a/tests/test_tiers.py b/tests/test_tiers.py new file mode 100644 index 0000000..02962f2 --- /dev/null +++ b/tests/test_tiers.py @@ -0,0 +1,46 @@ +import pytest +from circuitforge_core.tiers import can_use, TIERS, BYOK_UNLOCKABLE, LOCAL_VISION_UNLOCKABLE + + +def test_tiers_order(): + assert TIERS == ["free", "paid", "premium", "ultra"] + + +def test_free_feature_always_accessible(): + # Features not in FEATURES dict are free for everyone + assert can_use("nonexistent_feature", tier="free") is True + + +def test_paid_feature_blocked_for_free_tier(): + # Caller must register features — test via can_use with explicit min_tier + assert can_use("test_paid", tier="free", _features={"test_paid": "paid"}) is False + + +def test_paid_feature_accessible_for_paid_tier(): + assert can_use("test_paid", tier="paid", _features={"test_paid": "paid"}) is True + + +def test_premium_feature_accessible_for_ultra_tier(): + assert can_use("test_premium", tier="ultra", _features={"test_premium": "premium"}) is True + + +def test_byok_unlocks_byok_feature(): + byok_feature = next(iter(BYOK_UNLOCKABLE)) if BYOK_UNLOCKABLE else None + if byok_feature: + assert can_use(byok_feature, tier="free", has_byok=True) is True + + +def test_byok_does_not_unlock_non_byok_feature(): + assert can_use("test_paid", tier="free", has_byok=True, + _features={"test_paid": "paid"}) is False + + +def test_local_vision_unlocks_vision_feature(): + vision_feature = next(iter(LOCAL_VISION_UNLOCKABLE)) if LOCAL_VISION_UNLOCKABLE else None + if vision_feature: + assert can_use(vision_feature, tier="free", has_local_vision=True) is True + + +def test_local_vision_does_not_unlock_non_vision_feature(): + assert can_use("test_paid", tier="free", has_local_vision=True, + _features={"test_paid": "paid"}) is False