feat: add generalised tier system with BYOK and local vision unlocks
This commit is contained in:
parent
76506a390e
commit
97ee2c20b6
3 changed files with 125 additions and 0 deletions
3
circuitforge_core/tiers/__init__.py
Normal file
3
circuitforge_core/tiers/__init__.py
Normal file
|
|
@ -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"]
|
||||||
76
circuitforge_core/tiers/tiers.py
Normal file
76
circuitforge_core/tiers/tiers.py
Normal file
|
|
@ -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]
|
||||||
46
tests/test_tiers.py
Normal file
46
tests/test_tiers.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue