diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py index a9a9e3c..4a97707 100644 --- a/app/wizard/tiers.py +++ b/app/wizard/tiers.py @@ -1,7 +1,7 @@ """ Tier definitions and feature gates for Peregrine. -Tiers: free < paid < premium +Tiers: free < paid < premium < ultra (ultra reserved; no Peregrine features use it yet) FEATURES maps feature key → minimum tier required. Features not in FEATURES are available to all tiers (free). @@ -25,7 +25,11 @@ from __future__ import annotations import os as _os from pathlib import Path -TIERS = ["free", "paid", "premium"] +from circuitforge_core.tiers import ( + can_use as _core_can_use, + TIERS, + tier_label as _core_tier_label, +) # Maps feature key → minimum tier string required. # Features absent from this dict are free (available to all). @@ -132,25 +136,20 @@ def can_use( Returns False for unknown/invalid tier strings. """ effective_tier = demo_tier if (demo_tier is not None and _DEMO_MODE) else tier - required = FEATURES.get(feature) - if required is None: - return True # not gated — available to all + # Pass Peregrine's BYOK_UNLOCKABLE via has_byok collapse — core's frozenset is empty if has_byok and feature in BYOK_UNLOCKABLE: return True - try: - return TIERS.index(effective_tier) >= TIERS.index(required) - except ValueError: - return False # invalid tier string + return _core_can_use(feature, effective_tier, _features=FEATURES) def tier_label(feature: str, has_byok: bool = False) -> str: """Return a display label for a locked feature, or '' if free/unlocked.""" if has_byok and feature in BYOK_UNLOCKABLE: return "" - required = FEATURES.get(feature) - if required is None: + raw = _core_tier_label(feature, _features=FEATURES) + if not raw or raw == "free": return "" - return "🔒 Paid" if required == "paid" else "⭐ Premium" + return "🔒 Paid" if raw == "paid" else "⭐ Premium" def effective_tier( diff --git a/requirements.txt b/requirements.txt index 44c5506..8d9b611 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,9 @@ # Extracted from environment.yml for Docker pip installs # Keep in sync with environment.yml +# ── CircuitForge shared core ─────────────────────────────────────────────── +-e ../circuitforge-core + # ── Web UI ──────────────────────────────────────────────────────────────── streamlit>=1.35 watchdog @@ -78,3 +81,10 @@ lxml # ── Documentation ──────────────────────────────────────────────────────── mkdocs>=1.5 mkdocs-material>=9.5 + +# ── Vue SPA API backend ────────────────────────────────────────────────── +fastapi>=0.100.0 +uvicorn[standard]>=0.20.0 +PyJWT>=2.8.0 +cryptography>=40.0.0 +python-multipart>=0.0.6 diff --git a/scripts/db.py b/scripts/db.py index addc51f..f6ccb6e 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -9,30 +9,14 @@ from datetime import datetime from pathlib import Path from typing import Optional +from circuitforge_core.db import get_connection as _cf_get_connection + DEFAULT_DB = Path(os.environ.get("STAGING_DB", Path(__file__).parent.parent / "staging.db")) def get_connection(db_path: Path = DEFAULT_DB, key: str = "") -> "sqlite3.Connection": - """ - Open a database connection. - - In cloud mode with a key: uses SQLCipher (AES-256 encrypted, API-identical to sqlite3). - Otherwise: vanilla sqlite3. - - Args: - db_path: Path to the SQLite/SQLCipher database file. - key: SQLCipher encryption key (hex string). Empty = unencrypted. - """ - import os as _os - cloud_mode = _os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes") - if cloud_mode and key: - from pysqlcipher3 import dbapi2 as _sqlcipher - conn = _sqlcipher.connect(str(db_path)) - conn.execute(f"PRAGMA key='{key}'") - return conn - else: - import sqlite3 as _sqlite3 - return _sqlite3.connect(str(db_path)) + """Thin shim — delegates to circuitforge_core.db.get_connection.""" + return _cf_get_connection(db_path, key) CREATE_JOBS = """ diff --git a/scripts/llm_router.py b/scripts/llm_router.py index 5b8a469..45f9fc1 100644 --- a/scripts/llm_router.py +++ b/scripts/llm_router.py @@ -2,168 +2,18 @@ LLM abstraction layer with priority fallback chain. Reads config/llm.yaml. Tries backends in order; falls back on any error. """ -import os -import yaml -import requests from pathlib import Path -from openai import OpenAI + +from circuitforge_core.llm import LLMRouter as _CoreLLMRouter CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml" -class LLMRouter: +class LLMRouter(_CoreLLMRouter): + """Peregrine-specific LLMRouter — defaults to Peregrine's config/llm.yaml.""" + def __init__(self, config_path: Path = CONFIG_PATH): - with open(config_path) as f: - self.config = yaml.safe_load(f) - - def _is_reachable(self, base_url: str) -> bool: - """Quick health-check ping. Returns True if backend is up.""" - health_url = base_url.rstrip("/").removesuffix("/v1") + "/health" - try: - resp = requests.get(health_url, timeout=2) - return resp.status_code < 500 - except Exception: - return False - - def _resolve_model(self, client: OpenAI, model: str) -> str: - """Resolve __auto__ to the first model served by vLLM.""" - if model != "__auto__": - return model - models = client.models.list() - return models.data[0].id - - def complete(self, prompt: str, system: str | None = None, - model_override: str | None = None, - fallback_order: list[str] | None = None, - images: list[str] | None = None, - max_tokens: int | None = None) -> str: - """ - Generate a completion. Tries each backend in fallback_order. - - model_override: when set, replaces the configured model for - openai_compat backends (e.g. pass a research-specific ollama model). - fallback_order: when set, overrides config fallback_order for this - call (e.g. pass config["research_fallback_order"] for research tasks). - images: optional list of base64-encoded PNG/JPG strings. When provided, - backends without supports_images=true are skipped. vision_service backends - are only tried when images is provided. - Raises RuntimeError if all backends are exhausted. - """ - if os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"): - raise RuntimeError( - "AI inference is disabled in the public demo. " - "Run your own instance to use AI features." - ) - order = fallback_order if fallback_order is not None else self.config["fallback_order"] - for name in order: - backend = self.config["backends"][name] - - if not backend.get("enabled", True): - print(f"[LLMRouter] {name}: disabled, skipping") - continue - - supports_images = backend.get("supports_images", False) - is_vision_service = backend["type"] == "vision_service" - - # vision_service only used when images provided - if is_vision_service and not images: - print(f"[LLMRouter] {name}: vision_service skipped (no images)") - continue - - # non-vision backends skipped when images provided and they don't support it - if images and not supports_images and not is_vision_service: - print(f"[LLMRouter] {name}: no image support, skipping") - continue - - if is_vision_service: - if not self._is_reachable(backend["base_url"]): - print(f"[LLMRouter] {name}: unreachable, skipping") - continue - try: - resp = requests.post( - backend["base_url"].rstrip("/") + "/analyze", - json={ - "prompt": prompt, - "image_base64": images[0] if images else "", - }, - timeout=60, - ) - resp.raise_for_status() - print(f"[LLMRouter] Used backend: {name} (vision_service)") - return resp.json()["text"] - except Exception as e: - print(f"[LLMRouter] {name}: error — {e}, trying next") - continue - - elif backend["type"] == "openai_compat": - if not self._is_reachable(backend["base_url"]): - print(f"[LLMRouter] {name}: unreachable, skipping") - continue - try: - client = OpenAI( - base_url=backend["base_url"], - api_key=backend.get("api_key") or "any", - ) - raw_model = model_override or backend["model"] - model = self._resolve_model(client, raw_model) - messages = [] - if system: - messages.append({"role": "system", "content": system}) - if images and supports_images: - content = [{"type": "text", "text": prompt}] - for img in images: - content.append({ - "type": "image_url", - "image_url": {"url": f"data:image/png;base64,{img}"}, - }) - messages.append({"role": "user", "content": content}) - else: - messages.append({"role": "user", "content": prompt}) - - create_kwargs: dict = {"model": model, "messages": messages} - if max_tokens is not None: - create_kwargs["max_tokens"] = max_tokens - resp = client.chat.completions.create(**create_kwargs) - print(f"[LLMRouter] Used backend: {name} ({model})") - return resp.choices[0].message.content - - except Exception as e: - print(f"[LLMRouter] {name}: error — {e}, trying next") - continue - - elif backend["type"] == "anthropic": - api_key = os.environ.get(backend["api_key_env"], "") - if not api_key: - print(f"[LLMRouter] {name}: {backend['api_key_env']} not set, skipping") - continue - try: - import anthropic as _anthropic - client = _anthropic.Anthropic(api_key=api_key) - if images and supports_images: - content = [] - for img in images: - content.append({ - "type": "image", - "source": {"type": "base64", "media_type": "image/png", "data": img}, - }) - content.append({"type": "text", "text": prompt}) - else: - content = prompt - kwargs: dict = { - "model": backend["model"], - "max_tokens": 4096, - "messages": [{"role": "user", "content": content}], - } - if system: - kwargs["system"] = system - msg = client.messages.create(**kwargs) - print(f"[LLMRouter] Used backend: {name}") - return msg.content[0].text - except Exception as e: - print(f"[LLMRouter] {name}: error — {e}, trying next") - continue - - raise RuntimeError("All LLM backends exhausted") + super().__init__(config_path) # Module-level singleton for convenience diff --git a/tests/test_llm_router.py b/tests/test_llm_router.py index 0d5a897..09451f6 100644 --- a/tests/test_llm_router.py +++ b/tests/test_llm_router.py @@ -24,7 +24,7 @@ def test_router_uses_first_reachable_backend(): mock_response.choices[0].message.content = "hello" with patch.object(router, "_is_reachable", side_effect=[False, True, True, True, True]), \ - patch("scripts.llm_router.OpenAI") as MockOpenAI: + patch("circuitforge_core.llm.router.OpenAI") as MockOpenAI: instance = MockOpenAI.return_value instance.chat.completions.create.return_value = mock_response mock_model = MagicMock() @@ -54,7 +54,7 @@ def test_is_reachable_returns_false_on_connection_error(): router = LLMRouter(CONFIG_PATH) - with patch("scripts.llm_router.requests.get", side_effect=requests.ConnectionError): + with patch("circuitforge_core.llm.router.requests.get", side_effect=requests.ConnectionError): result = router._is_reachable("http://localhost:9999/v1") assert result is False @@ -92,8 +92,8 @@ def test_complete_skips_backend_without_image_support(tmp_path): mock_resp.status_code = 200 mock_resp.json.return_value = {"text": "B — collaborative"} - with patch("scripts.llm_router.requests.get") as mock_get, \ - patch("scripts.llm_router.requests.post") as mock_post: + with patch("circuitforge_core.llm.router.requests.get") as mock_get, \ + patch("circuitforge_core.llm.router.requests.post") as mock_post: # health check returns ok for vision_service mock_get.return_value = MagicMock(status_code=200) mock_post.return_value = mock_resp @@ -127,7 +127,7 @@ def test_complete_without_images_skips_vision_service(tmp_path): cfg_file.write_text(yaml.dump(cfg)) router = LLMRouter(config_path=cfg_file) - with patch("scripts.llm_router.requests.post") as mock_post: + with patch("circuitforge_core.llm.router.requests.post") as mock_post: try: router.complete("text only prompt") except RuntimeError: diff --git a/tests/test_wizard_tiers.py b/tests/test_wizard_tiers.py index d064d7e..660c244 100644 --- a/tests/test_wizard_tiers.py +++ b/tests/test_wizard_tiers.py @@ -8,7 +8,9 @@ from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKAB def test_tiers_list(): - assert TIERS == ["free", "paid", "premium"] + # Peregrine uses the core tier list; "ultra" is included but no features require it yet + assert TIERS[:3] == ["free", "paid", "premium"] + assert "ultra" in TIERS def test_can_use_free_feature_always():