linnet/app/tiers.py
pyr0ball b92285b4b3 feat: tier-aware sessions, session pinning, translation, audio settings, voice status
Backend:
- tiers.py: real license validation via Heimdall (self-hosted key or cloud user_id),
  30-min cache, BYOK exception for translation
- session_store.py: create_session() accepts tier/user_id/window_ms/transcribe_lang/
  num_speakers; tier-aware _should_reap() (Free: reap on subscriber leave, Paid: activity TTL);
  auto-snapshot paid sessions with history on reap; voice-ready probe on session start
- services/translator.py: DeepL-backed label translation, CF-managed and BYOK key paths,
  per-session label cache
- models/session.py, scene_event.py, service_event.py: new event types and session fields
- api/snapshots.py: GET /sessions/history + POST /session/resume (Paid tier only)
- api/dev.py: dev-only /dev/voice/stop|start|restart proxy to cf-orch agent
- migrations/002_session_snapshots.sql: session_snapshots table

Frontend:
- ComposeBar.vue: pre-session settings panel (window, language, speakers, Elcor toggle)
  with slide transition; passes options to startSession()
- stores/settings.ts: persistent user preferences (localStorage), survives page reload
- stores/session.ts: voiceReady/voiceError/voiceWarning states; SceneEvent/AccentEvent/
  ServiceEvent types; TTL timers for environ/queue badges; startSession() accepts opts
- composables/useToneStream.ts: handlers for scene-event, accent-event, service-event SSE
- NowPanel.vue: diarization speaker identity badge, acoustic scene badge, privacy risk indicator
- HistoryStrip.vue: per-speaker colour chips in history strip
- components/DevPanel.vue: dev-only cf-voice stop/restart controls + thread view toggle
- App.vue: DevPanel in footer (DEV only), threadView flag switches v0.1.x/v0.2.x preview

Tests:
- tests/test_pinning.py: 20 tests covering tier-aware reaping, snapshot creation,
  and translator behaviour
- tests/test_tiers.py: 17 tests covering key validation, cloud resolution,
  caching, require_paid() gate, BYOK exception
- All 72 tests passing
2026-06-24 15:25:46 -07:00

159 lines
5.3 KiB
Python

# app/tiers.py — tier gate checks
#
# Two validation paths:
# Self-hosted: LINNET_LICENSE_KEY env var (or key passed per-request)
# → cf-core validate_license() → Heimdall /v1/licenses/verify
# Cloud mode: user_id from X-CF-Session header
# → Heimdall /admin/cloud/resolve (admin token required)
#
# Both paths cache results in-process for 30 minutes so a brief Heimdall
# outage does not interrupt active sessions.
#
# BYOK: user-supplied DeepL key bypasses the Paid gate for translation only.
# Pass byok_deepl=True to require_paid() to enable this exception.
from __future__ import annotations
import logging
import time
import requests
from app.config import settings
logger = logging.getLogger(__name__)
_PRODUCT = "LNNT"
_CACHE_TTL = 1800 # 30 minutes — matches Heimdall offline grace window
# Cache: cache_key (str) -> (tier: str, expires_at: float)
_cache: dict[str, tuple[str, float]] = {}
# ── Internal helpers ──────────────────────────────────────────────────────────
def _cached(cache_key: str) -> str | None:
entry = _cache.get(cache_key)
if entry and time.monotonic() < entry[1]:
return entry[0]
return None
def _store(cache_key: str, tier: str) -> str:
_cache[cache_key] = (tier, time.monotonic() + _CACHE_TTL)
return tier
def _resolve_by_key(license_key: str) -> str:
"""Hit Heimdall /v1/licenses/verify for a raw key. Returns tier string."""
cache_key = f"key:{license_key}"
cached = _cached(cache_key)
if cached is not None:
return cached
try:
resp = requests.post(
f"{settings.heimdall_url}/v1/licenses/verify",
json={"key": license_key, "min_tier": "free"},
timeout=5,
)
if not resp.ok:
logger.warning("[tiers] Heimdall /verify returned %s", resp.status_code)
return _store(cache_key, "free")
data = resp.json()
if not data.get("valid", False):
return _store(cache_key, "free")
tier = data.get("tier", "free") or "free"
return _store(cache_key, tier)
except Exception as exc:
logger.warning("[tiers] Heimdall key validation failed: %s", exc)
# Do NOT cache on network failure — allow retry after next request
return "free"
def _resolve_by_user(user_id: str) -> str:
"""Hit Heimdall /admin/cloud/resolve for a cloud user. Returns tier string."""
if not settings.heimdall_admin_token:
logger.warning("[tiers] HEIMDALL_ADMIN_TOKEN not set — defaulting to free")
return "free"
cache_key = f"user:{user_id}"
cached = _cached(cache_key)
if cached is not None:
return cached
try:
resp = requests.post(
f"{settings.heimdall_url}/admin/cloud/resolve",
json={"user_id": user_id, "product": _PRODUCT},
headers={"Authorization": f"Bearer {settings.heimdall_admin_token}"},
timeout=5,
)
if not resp.ok:
logger.warning("[tiers] Heimdall /cloud/resolve returned %s", resp.status_code)
return _store(cache_key, "free")
data = resp.json()
tier = data.get("tier", "free") or "free"
return _store(cache_key, tier)
except Exception as exc:
logger.warning("[tiers] Heimdall cloud resolve failed: %s", exc)
return "free"
# ── Public API ────────────────────────────────────────────────────────────────
def get_tier(
license_key: str | None = None,
user_id: str | None = None,
) -> str:
"""
Return the active tier for this request context.
Precedence:
1. user_id (cloud mode — set by CloudAuthMiddleware on request.state.cf_user)
2. license_key argument (self-hosted, passed per-request)
3. LINNET_LICENSE_KEY env var (self-hosted, instance-wide)
4. "free" (no key configured)
"""
if user_id:
return _resolve_by_user(user_id)
key = license_key or settings.linnet_license_key
if not key:
return "free"
return _resolve_by_key(key)
def is_paid(
license_key: str | None = None,
user_id: str | None = None,
) -> bool:
"""Return True if the resolved tier is paid or premium."""
return get_tier(license_key=license_key, user_id=user_id) in ("paid", "premium")
def require_paid(
license_key: str | None = None,
user_id: str | None = None,
byok_deepl: bool = False,
) -> None:
"""
Raise HTTP 402 if caller doesn't have a Paid+ license.
byok_deepl=True skips the check — the caller has provided their own
DeepL API key and bypasses the Paid gate for translation only.
"""
if byok_deepl:
return
if not is_paid(license_key=license_key, user_id=user_id):
from fastapi import HTTPException
raise HTTPException(
status_code=402,
detail="This feature requires a Linnet Paid license. "
"Get one at circuitforge.tech.",
)
# ── Backwards-compat shim (old call sites that pass key as positional) ────────
BYOK_UNLOCKABLE = ["cloud_stt", "cloud_tts", "session_pinning"]