wrolpi/modules/ai/catalog.py
Roland cc0880c662
ai: publish tooling + real model hashes in the bundled catalog
publish_ai_models.py produces the CDN artifacts for every AI_MODELS
entry — GPG-signed .meta4 sidecars (reusing the map trust chain) and
the signed ai/models.json manifest — and uploads them with s3cmd.
The bundled catalog now carries the real sha256/size of the two Qwen
GGUFs, which match Hugging Face's LFS records for the Apache 2.0
unsloth quants, and whose signed meta4s verify against the committed
public key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:26:45 -06:00

108 lines
4 KiB
Python

"""The AI model catalog.
The bundled AI_MODELS constant is the offline fallback (like the map module's MAP_REGIONS);
the CDN manifest at ai/models.json is fetched at browse/download time and GPG-verified with the
committed WROLPi public key. The release process refreshes AI_MODELS from the published manifest.
"""
import json
import tempfile
from pathlib import Path
import psutil
from wrolpi.common import aiohttp_get, get_media_directory, logger, verify_gpg_signature
logger = logger.getChild(__name__)
CDN = 'https://wrolpi.nyc3.cdn.digitaloceanspaces.com'
MODELS_MANIFEST_URL = f'{CDN}/ai/models.json'
# Sizes are bytes; sha256 values match the published manifest (wrolpi/scripts/publish_ai_models.py
# refreshes both). The meta4 sidecar (GPG-verified at download time) is what enforces the hash.
AI_MODELS = [
dict(
name='Qwen3-1.7B-Q4_K_M.gguf',
tier='small',
url=f'{CDN}/ai/Qwen3-1.7B-Q4_K_M.gguf',
sha256='b139949c5bd74937ad8ed8c8cf3d9ffb1e99c866c823204dc42c0d91fa181897',
size=1_107_409_472,
min_ram_gb=4,
default_context=8_192,
license='Apache 2.0',
description='The default assistant for 4GB devices (Pi 4/5). Best tool-calling per byte.',
),
dict(
name='Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
tier='medium',
url=f'{CDN}/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
sha256='3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597',
size=2_497_281_120,
min_ram_gb=8,
default_context=16_384,
license='Apache 2.0',
description='The default assistant for 8GB+ devices. A standout small tool-caller.',
),
]
def get_models_directory() -> Path:
"""Where GGUF model files are stored. Always inside the media directory."""
return get_media_directory() / 'ai/models'
async def fetch_models_manifest(url: str = None) -> dict:
"""Fetch the models manifest from the CDN and verify its GPG signature (map-manifest pattern)."""
url = url or MODELS_MANIFEST_URL
sig_url = f'{url}.sig'
async with aiohttp_get(url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f'Failed to fetch models manifest: HTTP {response.status}')
manifest_bytes = await response.content.read()
async with aiohttp_get(sig_url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f'Failed to fetch models manifest signature: HTTP {response.status}')
signature_bytes = await response.content.read()
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = Path(tmpdir) / 'models.json'
sig_path = Path(tmpdir) / 'models.json.sig'
manifest_path.write_bytes(manifest_bytes)
sig_path.write_bytes(signature_bytes)
if not await verify_gpg_signature(manifest_path, sig_path):
raise RuntimeError('Models manifest GPG signature verification failed')
logger.info('Models manifest GPG signature verified successfully')
return json.loads(manifest_bytes)
async def get_models_catalog() -> (list, str):
"""The list of downloadable models, and where it came from ('cdn' or 'bundled').
The CDN manifest wins when reachable; the bundled constant keeps the Manage tab working
offline."""
try:
manifest = await fetch_models_manifest()
models = manifest.get('models') or []
if models:
return models, 'cdn'
except Exception as e:
logger.debug(f'Could not fetch models manifest, using bundled catalog: {e}')
return AI_MODELS, 'bundled'
def get_total_ram_bytes() -> int:
return psutil.virtual_memory().total
def recommend_tier(total_ram_bytes: int = None) -> str:
"""Recommend a model tier for this device's RAM. Other tiers stay selectable with a warning."""
total_ram_bytes = total_ram_bytes if total_ram_bytes is not None else get_total_ram_bytes()
gb = total_ram_bytes / 1024 ** 3
if gb < 6:
return 'small'
if gb < 24:
return 'medium'
return 'large'