mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
ai.yaml is the source of truth (opt-in: enabled=False by default) with active model, idle-unload minutes, context override, and tool-call bound. The model catalog follows the map pattern: a bundled constant keeps the Manage tab working offline, the CDN manifest (ai/models.json + detached GPG signature) wins when reachable. GGUF downloads clone the map-extract flow — the per-file meta4 is GPG-verified once and the same bytes handed to aria2c, so the verified hash is the enforced one; unverified downloads are refused. Models land in <media>/ai/models/ (gated by the existing media_mounted download rules). Manage endpoints (catalog with RAM-tier recommendation, settings) live under /api/ai/manage/ which the future agent loop excludes from its tool catalog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
5 KiB
Python
120 lines
5 KiB
Python
"""Downloads GGUF model files from the WROLPi CDN into <media>/ai/models/.
|
|
|
|
Follows the map-extract pattern: the per-file .meta4 sidecar is fetched once and its hash
|
|
signature GPG-verified, then the same bytes are handed to aria2c so the hash that was verified
|
|
is the hash that is enforced. Downloads are resumable and respect the media_mounted rules via
|
|
the download manager."""
|
|
import pathlib
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from modules.ai.catalog import get_models_directory
|
|
from modules.map.downloader import verify_meta4_signature
|
|
from wrolpi.common import logger
|
|
from wrolpi.downloader import Download, DownloadContext, Downloader, DownloadResult
|
|
from wrolpi.events import Events
|
|
|
|
__all__ = ['ModelDownloader', 'PreparedModel', 'ExecutedModel', 'ai_model_downloader']
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
@dataclass
|
|
class PreparedModel:
|
|
"""Plan produced by ModelDownloader.prepare_download."""
|
|
name: str
|
|
models_directory: pathlib.Path
|
|
output_path: pathlib.Path
|
|
tmp_path: pathlib.Path
|
|
already_done: bool = False
|
|
error: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class ExecutedModel:
|
|
"""Output of ModelDownloader.execute_download."""
|
|
name: str
|
|
output_path: pathlib.Path
|
|
skipped: bool = False
|
|
error: Optional[str] = None
|
|
|
|
|
|
class ModelDownloader(Downloader):
|
|
"""Downloads one GGUF model file via aria2c with GPG-verified hash checking."""
|
|
|
|
name = 'ai_model'
|
|
listable = False
|
|
|
|
def prepare_download(self, session: Session, download: Download) -> PreparedModel:
|
|
name = download.url.rstrip('/').rsplit('/', 1)[-1]
|
|
models_directory = get_models_directory()
|
|
output_path = models_directory / name
|
|
tmp_path = models_directory / f'{name}.tmp'
|
|
|
|
if not name.endswith('.gguf'):
|
|
return PreparedModel(name=name, models_directory=models_directory, output_path=output_path,
|
|
tmp_path=tmp_path, error=f'Not a GGUF model URL: {download.url}')
|
|
|
|
prepared = PreparedModel(name=name, models_directory=models_directory, output_path=output_path,
|
|
tmp_path=tmp_path)
|
|
if output_path.is_file():
|
|
logger.info(f'{name} already exists, skipping download')
|
|
prepared.already_done = True
|
|
return prepared
|
|
|
|
models_directory.mkdir(parents=True, exist_ok=True)
|
|
# Clean up any leftover temp file; aria2c resumes its own .aria2 checkpoints.
|
|
tmp_path.unlink(missing_ok=True)
|
|
return prepared
|
|
|
|
async def execute_download(self, prepared: PreparedModel, ctx: DownloadContext,
|
|
download: Download = None) -> ExecutedModel:
|
|
if prepared.error:
|
|
return ExecutedModel(name=prepared.name, output_path=prepared.output_path, error=prepared.error)
|
|
if prepared.already_done:
|
|
return ExecutedModel(name=prepared.name, output_path=prepared.output_path, skipped=True)
|
|
|
|
url = download.url
|
|
|
|
# Fetch the meta4 once, GPG-verify the hash signature before downloading.
|
|
meta4_contents = await self.get_meta4_contents(url)
|
|
if not meta4_contents:
|
|
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
|
|
error=f'No meta4 available for {prepared.name}; refusing an unverified download')
|
|
if not await verify_meta4_signature(meta4_contents):
|
|
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
|
|
error=f'meta4 hash signature verification failed for {prepared.name}')
|
|
logger.info(f'meta4 signature verified for {prepared.name}')
|
|
|
|
downloaded_path = None
|
|
try:
|
|
downloaded_path = await self.download_file(
|
|
download, url, prepared.models_directory,
|
|
check_for_meta4=False, concurrent=1, meta4_xml=meta4_contents,
|
|
ctx=ctx,
|
|
)
|
|
downloaded_path.rename(prepared.tmp_path)
|
|
except Exception as e:
|
|
logger.error(f'Model download failed for {prepared.name}: {e}')
|
|
prepared.tmp_path.unlink(missing_ok=True)
|
|
if downloaded_path:
|
|
downloaded_path.unlink(missing_ok=True)
|
|
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
|
|
error=f'Download failed: {str(e)[:500]}')
|
|
|
|
# Replace atomically.
|
|
prepared.tmp_path.rename(prepared.output_path)
|
|
return ExecutedModel(name=prepared.name, output_path=prepared.output_path)
|
|
|
|
def finalize_download(self, session: Session, download: Download, executed: ExecutedModel) -> DownloadResult:
|
|
if executed.error:
|
|
return DownloadResult(success=False, error=executed.error)
|
|
if not executed.skipped:
|
|
Events.send_user_notify(f'AI model downloaded: {executed.name}', url='/ai/manage')
|
|
return DownloadResult(success=True, location='/ai/manage')
|
|
|
|
|
|
# Instantiating registers with the download manager.
|
|
ai_model_downloader = ModelDownloader()
|