feat: migrate to circuitforge-core for db, llm router, and tiers
Some checks failed
CI / test (push) Failing after 24s

This commit is contained in:
pyr0ball 2026-03-25 11:44:19 -07:00
parent 608e0fa922
commit 818e46c17e
6 changed files with 39 additions and 194 deletions

View file

@ -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(

View file

@ -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

View file

@ -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 = """

View file

@ -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

View file

@ -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:

View file

@ -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():