diff --git a/main.py b/main.py index 92cd3506..1ce06cd7 100755 --- a/main.py +++ b/main.py @@ -289,6 +289,10 @@ async def start_single_tasks(app: Sanic): from wrolpi.downloader import get_download_cache_config get_download_cache_config().import_config() logger.debug('download cache config imported') + with log_and_suppress(Exception, message='Failed to import ai config'): + from modules.ai.config import get_ai_config + get_ai_config().import_config() + logger.debug('ai config imported') logger.debug('start_single_tasks waiting for db...') async with flags.db_up.wait_for(): diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py index 8f05d9c0..a34acee8 100644 --- a/modules/ai/__init__.py +++ b/modules/ai/__init__.py @@ -1 +1,2 @@ from modules.ai.api import ai_bp # noqa +from modules.ai.downloader import ModelDownloader # noqa Instantiation registers the downloader. diff --git a/modules/ai/api.py b/modules/ai/api.py index b65f6ec3..4a72ef94 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -21,12 +21,13 @@ from wrolpi.api_utils import json_response from wrolpi.collections.lib import search_collections from wrolpi.common import api_param_limiter, get_media_directory, logger, wrol_mode_enabled from wrolpi.downloader import download_manager -from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile +from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError from wrolpi.files.lib import search_files -from wrolpi.vars import DOCKERIZED +from wrolpi.vars import DOCKERIZED, IS_RPI4 from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse -from . import help_docs, lib, schema +from . import catalog, help_docs, lib, schema +from .config import get_ai_config from .controller_client import controller_get from .errors import ControllerUnavailable @@ -513,6 +514,68 @@ async def ai_list_disks(_: Request): return json_response(ret) +# --------------------------------------------------------------------------- +# Manage endpoints — for the AI page's Manage tab, NOT part of the model's tool catalog. +# The agent loop excludes everything under /api/ai/manage/ when building tool definitions. +# --------------------------------------------------------------------------- + +@ai_bp.get('/manage/catalog') +@openapi.description('The AI model catalog, download/active state, and the recommended tier for this' + ' device. For the Manage tab, not the model.') +async def manage_catalog(_: Request): + models, source = await catalog.get_models_catalog() + models_directory = catalog.get_models_directory() + downloaded = {i.name for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else set() + disk_usage = sum(i.stat().st_size for i in models_directory.glob('*')) if models_directory.is_dir() else 0 + + config = get_ai_config() + models = [dict(i, downloaded=i['name'] in downloaded, active=i['name'] == config.active_model) + for i in models] + + total_ram = catalog.get_total_ram_bytes() + return json_response(dict( + models=models, + catalog_source=source, + total_ram=total_ram, + recommended_tier=catalog.recommend_tier(total_ram), + slow_hardware=IS_RPI4, + models_directory=str(models_directory), + disk_usage=disk_usage, + enabled=config.enabled, + active_model=config.active_model, + idle_unload_minutes=config.idle_unload_minutes, + context_size=config.context_size, + )) + + +@ai_bp.post('/manage/settings') +@openapi.definition( + description='Update the AI settings (ai.yaml). For the Manage tab, not the model.', + body=schema.AIManageSettingsRequest, +) +@validate(schema.AIManageSettingsRequest) +async def manage_settings(_: Request, body: schema.AIManageSettingsRequest): + config = get_ai_config() + if body.active_model is not None: + if body.active_model and not (catalog.get_models_directory() / body.active_model).is_file(): + raise ValidationError(f'Model is not downloaded: {body.active_model}') + config.active_model = body.active_model + if body.enabled is not None: + config.enabled = body.enabled + if body.idle_unload_minutes is not None: + if body.idle_unload_minutes < 1: + raise ValidationError('idle_unload_minutes must be at least 1') + config.idle_unload_minutes = body.idle_unload_minutes + if body.context_size is not None: + config.context_size = body.context_size + return json_response(dict( + enabled=config.enabled, + active_model=config.active_model, + idle_unload_minutes=config.idle_unload_minutes, + context_size=config.context_size, + )) + + @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', diff --git a/modules/ai/catalog.py b/modules/ai/catalog.py new file mode 100644 index 00000000..dadf3579 --- /dev/null +++ b/modules/ai/catalog.py @@ -0,0 +1,108 @@ +"""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 are filled from the published manifest at release time. The +# meta4 sidecar (GPG-verified at download time) is what actually 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='', + size=1_200_000_000, + 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='', + size=2_600_000_000, + 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' diff --git a/modules/ai/config.py b/modules/ai/config.py new file mode 100644 index 00000000..29569d36 --- /dev/null +++ b/modules/ai/config.py @@ -0,0 +1,101 @@ +"""The ai.yaml config — the source of truth for the AI assistant's state. + +AI is opt-in: nothing is downloaded and llama-server does not run until the user enables it on +the Manage tab. YAML-only (no database).""" +import contextlib +from dataclasses import dataclass +from typing import Optional + +from wrolpi.common import ConfigFile + + +@dataclass +class AIConfigValidator: + version: int = None + enabled: bool = None + active_model: str = None + idle_unload_minutes: int = None + context_size: Optional[int] = None + max_tool_calls: int = None + + +class AIConfig(ConfigFile): + file_name = 'ai.yaml' + default_config = dict( + version=0, + enabled=False, + active_model='', # a GGUF file name in /ai/models/ + idle_unload_minutes=15, + context_size=None, # None uses the active model's default from the catalog. + max_tool_calls=6, + ) + validator = AIConfigValidator + + def import_config(self, file=None, send_events=False): + super().import_config(file, send_events) + # YAML-only config (no database); a successful read is a successful import. + self.successful_import = True + + @property + def enabled(self) -> bool: + return self._config['enabled'] + + @enabled.setter + def enabled(self, value: bool): + self.update({'enabled': bool(value)}) + + @property + def active_model(self) -> str: + return self._config['active_model'] + + @active_model.setter + def active_model(self, value: str): + self.update({'active_model': value or ''}) + + @property + def idle_unload_minutes(self) -> int: + return self._config['idle_unload_minutes'] + + @idle_unload_minutes.setter + def idle_unload_minutes(self, value: int): + self.update({'idle_unload_minutes': int(value)}) + + @property + def context_size(self) -> Optional[int]: + return self._config['context_size'] + + @context_size.setter + def context_size(self, value: Optional[int]): + self.update({'context_size': int(value) if value else None}) + + @property + def max_tool_calls(self) -> int: + return self._config['max_tool_calls'] + + @max_tool_calls.setter + def max_tool_calls(self, value: int): + self.update({'max_tool_calls': int(value)}) + + +AI_CONFIG: AIConfig = AIConfig() + +# Test override (see get_ai_config). +TEST_AI_CONFIG: Optional[AIConfig] = None + + +def get_ai_config() -> AIConfig: + global TEST_AI_CONFIG + if isinstance(TEST_AI_CONFIG, ConfigFile): + return TEST_AI_CONFIG + return AI_CONFIG + + +@contextlib.contextmanager +def ai_config_context(): + """Used to create a test config.""" + global TEST_AI_CONFIG + TEST_AI_CONFIG = AIConfig() + try: + yield TEST_AI_CONFIG + finally: + TEST_AI_CONFIG = None diff --git a/modules/ai/conftest.py b/modules/ai/conftest.py index 25fcf989..7fdd759b 100644 --- a/modules/ai/conftest.py +++ b/modules/ai/conftest.py @@ -1,3 +1,17 @@ # AI module test fixtures. The docs conftest is not star-imported by the root conftest, but the # AI endpoints cover docs; import its fixtures so AI tests can use doc_factory. +import pytest + +from modules.ai.config import ai_config_context from modules.docs.conftest import * # noqa +from wrolpi.api_utils import api_app + + +@pytest.fixture +def test_ai_config(test_directory, async_client): + """An isolated ai.yaml config for one test.""" + (test_directory / 'config').mkdir(exist_ok=True) + config_path = test_directory / 'config/ai.yaml' + with ai_config_context() as config: + config.initialize(api_app.shared_ctx.ai_config) + yield config_path diff --git a/modules/ai/downloader.py b/modules/ai/downloader.py new file mode 100644 index 00000000..30d95bec --- /dev/null +++ b/modules/ai/downloader.py @@ -0,0 +1,120 @@ +"""Downloads GGUF model files from the WROLPi CDN into /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() diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 1c48ef74..7d7390db 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -55,6 +55,14 @@ class AIHelpSearchRequest: limit: int = 5 +@dataclasses.dataclass +class AIManageSettingsRequest: + enabled: Optional[bool] = None + active_model: Optional[str] = None + idle_unload_minutes: Optional[int] = None + context_size: Optional[int] = None + + @dataclasses.dataclass class AISearchResponse: results: list = dataclasses.field(default_factory=list) diff --git a/modules/ai/test/test_config_catalog.py b/modules/ai/test/test_config_catalog.py new file mode 100644 index 00000000..6fca689a --- /dev/null +++ b/modules/ai/test/test_config_catalog.py @@ -0,0 +1,163 @@ +"""Tests for the ai.yaml config, the model catalog, and the GGUF downloader.""" +import json +from http import HTTPStatus +from unittest import mock + +import pytest +import yaml + +from modules.ai import catalog +from modules.ai.config import get_ai_config +from modules.ai.downloader import ai_model_downloader +from wrolpi.downloader import Download + + +def test_ai_config_defaults_and_update(test_ai_config): + """The config has safe defaults (AI off) and round-trips values.""" + config = get_ai_config() + assert config.enabled is False + assert config.active_model == '' + assert config.idle_unload_minutes == 15 + assert config.context_size is None + assert config.max_tool_calls == 6 + + config.enabled = True + config.active_model = 'Qwen3-1.7B-Q4_K_M.gguf' + config.context_size = 8_192 + assert get_ai_config().enabled is True + assert get_ai_config().active_model == 'Qwen3-1.7B-Q4_K_M.gguf' + assert get_ai_config().context_size == 8_192 + + +@pytest.mark.asyncio +async def test_ai_config_import(test_ai_config): + """A hand-edited ai.yaml is the source of truth on import.""" + test_ai_config.write_text(yaml.dump(dict( + version=1, enabled=True, active_model='custom.gguf', idle_unload_minutes=5, + context_size=None, max_tool_calls=4, + ))) + config = get_ai_config() + config.import_config(test_ai_config) + assert config.enabled is True + assert config.active_model == 'custom.gguf' + assert config.idle_unload_minutes == 5 + assert config.max_tool_calls == 4 + assert config.successful_import is True + + +@pytest.mark.asyncio +async def test_models_catalog_fallback(async_client): + """The bundled catalog is served when the CDN is unreachable.""" + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + models, source = await catalog.get_models_catalog() + assert source == 'bundled' + assert models == catalog.AI_MODELS + + manifest = dict(version=1, models=[dict(name='new.gguf', tier='small')]) + with mock.patch('modules.ai.catalog.fetch_models_manifest', return_value=manifest): + models, source = await catalog.get_models_catalog() + assert source == 'cdn' + assert models[0]['name'] == 'new.gguf' + + +def test_recommend_tier(): + gb = 1024 ** 3 + assert catalog.recommend_tier(4 * gb) == 'small' + assert catalog.recommend_tier(8 * gb) == 'medium' + assert catalog.recommend_tier(32 * gb) == 'large' + + +@pytest.mark.asyncio +async def test_model_downloader(test_session, test_directory, test_download_manager): + """The model downloader verifies the meta4 signature, stages, and lands in /ai/models.""" + url = 'https://example.com/ai/Test-Model-Q4_K_M.gguf' + download = Download(url=url, downloader='ai_model') + + prepared = ai_model_downloader.prepare_download(test_session, download) + assert prepared.error is None + assert prepared.output_path == test_directory / 'ai/models/Test-Model-Q4_K_M.gguf' + assert prepared.models_directory.is_dir() + + meta4 = b'fake' + + async def fake_download_file(download_, url_, directory, **kwargs): + assert kwargs['meta4_xml'] == meta4 + path = directory / 'Test-Model-Q4_K_M.gguf' + path.write_bytes(b'GGUF fake weights') + return path + + with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=meta4), \ + mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=True), \ + mock.patch.object(ai_model_downloader, 'download_file', side_effect=fake_download_file): + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert executed.error is None + assert prepared.output_path.is_file() + + result = ai_model_downloader.finalize_download(test_session, download, executed) + assert result.success is True + + # A second download of the same model is skipped. + prepared = ai_model_downloader.prepare_download(test_session, download) + assert prepared.already_done is True + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert executed.skipped is True + + +@pytest.mark.asyncio +async def test_model_downloader_refuses_unverified(test_session, test_directory, test_download_manager): + """No meta4, or a bad signature, refuses the download.""" + url = 'https://example.com/ai/Test-Model.gguf' + download = Download(url=url, downloader='ai_model') + prepared = ai_model_downloader.prepare_download(test_session, download) + + with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=None): + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert 'refusing an unverified download' in executed.error + + with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=b''), \ + mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=False): + executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download) + assert 'signature verification failed' in executed.error + assert not prepared.output_path.exists() + + # Not a GGUF URL is refused in prepare. + bad = ai_model_downloader.prepare_download(test_session, Download(url='https://example.com/x.zip')) + assert 'Not a GGUF model URL' in bad.error + + +@pytest.mark.asyncio +async def test_ai_manage_endpoints(async_client, test_directory, test_ai_config): + """The Manage tab's catalog and settings endpoints.""" + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + request, response = await async_client.get('/api/ai/manage/catalog') + assert response.status_code == HTTPStatus.OK + assert response.json['catalog_source'] == 'bundled' + assert response.json['enabled'] is False + assert response.json['recommended_tier'] in ('small', 'medium', 'large') + assert all(i['downloaded'] is False for i in response.json['models']) + + # Selecting a model that is not downloaded is refused. + content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf') + request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content)) + assert response.status_code == HTTPStatus.BAD_REQUEST + + # Once the file exists it can be selected, and shows downloaded/active in the catalog. + models_dir = test_directory / 'ai/models' + models_dir.mkdir(parents=True) + (models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF') + content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf', enabled=True, idle_unload_minutes=5) + request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['active_model'] == 'Qwen3-1.7B-Q4_K_M.gguf' + assert response.json['enabled'] is True + + with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')): + request, response = await async_client.get('/api/ai/manage/catalog') + model = next(i for i in response.json['models'] if i['name'] == 'Qwen3-1.7B-Q4_K_M.gguf') + assert model['downloaded'] is True and model['active'] is True + assert response.json['disk_usage'] > 0 + + # Bad idle minutes. + request, response = await async_client.post('/api/ai/manage/settings', + content=json.dumps(dict(idle_unload_minutes=0))) + assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index d961cdf2..e6c59f69 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -35,6 +35,7 @@ def attach_shared_contexts(app: Sanic): app.shared_ctx.download_cache_config = manager.dict() app.shared_ctx.map_pins_config = manager.dict() app.shared_ctx.flasher_config = manager.dict() + app.shared_ctx.ai_config = manager.dict() # Shared dicts. app.shared_ctx.uploaded_files = manager.dict() app.shared_ctx.status = manager.dict() @@ -126,6 +127,7 @@ def reset_shared_contexts(app: Sanic): app.shared_ctx.domains_config.clear() app.shared_ctx.archive_downloader_config.clear() app.shared_ctx.download_cache_config.clear() + app.shared_ctx.ai_config.clear() # Shared dicts. app.shared_ctx.uploaded_files.clear() app.shared_ctx.status.clear() @@ -313,3 +315,9 @@ def initialize_configs_contexts(app: Sanic): FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config) except Exception as e: logger.error(f'Failed to initialize in-memory flasher config: {e}') + + try: + from modules.ai.config import AI_CONFIG + AI_CONFIG.initialize(app.shared_ctx.ai_config) + except Exception as e: + logger.error(f'Failed to initialize in-memory ai config: {e}')