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
159 lines
5.3 KiB
Python
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"]
|