From f95810e8a7758c3b69ef05b88492381402ad92b2 Mon Sep 17 00:00:00 2001 From: Apple Date: Tue, 2 Dec 2025 03:13:01 -0800 Subject: [PATCH] fix(nodes): Normalize Router/Swapper endpoints and fix NODE2 display Major changes: - Normalize get_node_endpoints to use ENV vars (ROUTER_BASE_URL, SWAPPER_BASE_URL) - Remove node_id-based URL selection logic - Add fallback direct API call in get_node_swapper_detail - Fix Swapper API endpoint (/models instead of /api/v1/models) - Add router_healthy and router_version to node_heartbeat fallback - Add ENV vars to docker-compose for Router/Swapper URLs Documentation: - Add TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md with full task description - Add NODE2_GUARDIAN_SETUP.md with setup instructions This fixes: - Swapper models not showing for NODE1 and NODE2 - DAGI Router agents not showing for NODE2 - Router/Swapper showing as Down/Degraded when they're actually up --- docker-compose.city-space.yml | 3 + docs/NODE2_GUARDIAN_SETUP.md | 222 ++++++++++++++++++ .../TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md | 183 +++++++++++++++ services/city-service/repo_city.py | 49 ++-- services/city-service/routes_city.py | 97 +++++--- 5 files changed, 509 insertions(+), 45 deletions(-) create mode 100644 docs/NODE2_GUARDIAN_SETUP.md create mode 100644 docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md diff --git a/docker-compose.city-space.yml b/docker-compose.city-space.yml index ea7b76dd..7622f320 100644 --- a/docker-compose.city-space.yml +++ b/docker-compose.city-space.yml @@ -25,6 +25,9 @@ services: - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} - ASSETS_BUCKET=${ASSETS_BUCKET:-daarion-assets} - ASSETS_PUBLIC_BASE_URL=${ASSETS_PUBLIC_BASE_URL:-https://assets.daarion.space/daarion-assets} + # DAGI Router & Swapper configuration (from .env) + - ROUTER_BASE_URL=${ROUTER_BASE_URL:-http://dagi-router:9102} + - SWAPPER_BASE_URL=${SWAPPER_BASE_URL:-http://swapper-service:8890} depends_on: - dagi-postgres - dagi-nats diff --git a/docs/NODE2_GUARDIAN_SETUP.md b/docs/NODE2_GUARDIAN_SETUP.md new file mode 100644 index 00000000..3db62719 --- /dev/null +++ b/docs/NODE2_GUARDIAN_SETUP.md @@ -0,0 +1,222 @@ +# Налаштування Node Guardian для НОДА2 + +## Контекст + +Node Guardian — це сервіс, який періодично збирає метрики з DAGI Router та Swapper Service та оновлює їх в `node_cache` таблиці БД. Це дозволяє UI показувати актуальний стан нод. + +## Налаштування для НОДА2 (MacBook) + +### 1. Environment Variables + +Створіть `.env` файл або додайте до існуючого: + +```bash +# Node Identity +NODE_ID=node-2-macbook-m4max +NODE_NAME=НОДА2 +NODE_ENVIRONMENT=development +NODE_ROLES=gpu,ai_runtime +NODE_HOSTNAME=$(hostname) + +# City Service URL (HTTPS для проді) +CITY_SERVICE_URL=https://daarion.space/api/city + +# Node-specific service URLs (для НОДА2 - localhost) +NODE_SWAPPER_URL=http://localhost:8890 +NODE_ROUTER_URL=http://localhost:9102 + +# Guardian interval (секунди) +GUARDIAN_INTERVAL=60 +``` + +### 2. Запуск Node Guardian + +#### Як фонове завдання (рекомендовано): + +```bash +# Створити systemd service +sudo nano /etc/systemd/system/node-guardian.service +``` + +Вміст файлу: + +```ini +[Unit] +Description=DAARION Node Guardian +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=/path/to/microdao-daarion +EnvironmentFile=/path/to/.env +ExecStart=/usr/bin/python3 /path/to/microdao-daarion/scripts/node-guardian-loop.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Активувати: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable node-guardian +sudo systemctl start node-guardian +sudo systemctl status node-guardian +``` + +#### Або вручну: + +```bash +cd /path/to/microdao-daarion +python3 scripts/node-guardian-loop.py +``` + +### 3. Перевірка роботи + +#### Перевірити логи: + +```bash +# Якщо systemd service +sudo journalctl -u node-guardian -f + +# Або якщо запущено вручну - дивитись stdout +``` + +#### Перевірити в БД: + +```sql +-- Перевірити чи оновлюються метрики для НОДА2 +SELECT + node_id, + swapper_healthy, + swapper_models_loaded, + swapper_models_total, + router_healthy, + router_version, + last_heartbeat, + updated_at +FROM node_cache +WHERE node_id = 'node-2-macbook-m4max' +ORDER BY updated_at DESC +LIMIT 1; +``` + +#### Перевірити Swapper State: + +```sql +SELECT + node_id, + swapper_state->'models' as models +FROM node_cache +WHERE node_id = 'node-2-macbook-m4max' + AND swapper_state IS NOT NULL; +``` + +### 4. Troubleshooting + +#### Guardian не оновлює метрики + +1. Перевірити з'єднання з city-service: + ```bash + curl -v https://daarion.space/api/city/health + ``` + +2. Перевірити чи Swapper доступний: + ```bash + curl http://localhost:8890/health + curl http://localhost:8890/models + ``` + +3. Перевірити чи Router доступний: + ```bash + curl http://localhost:9102/health + ``` + +4. Перевірити логи guardian на помилки: + ```bash + sudo journalctl -u node-guardian --since "10 minutes ago" | grep -i error + ``` + +#### Помилки авторизації (401/403) + +- Перевірити що `CITY_SERVICE_URL` правильний +- Перевірити що немає старого токена в конфігурації +- Перевірити що city-service доступний з MacBook + +#### Swapper показує 0 моделей + +1. Перевірити чи Swapper реально має моделі: + ```bash + curl http://localhost:8890/models | jq + ``` + +2. Перевірити чи guardian правильно парсить відповідь: + - Дивитись логи guardian на повідомлення про Swapper metrics + +3. Перевірити чи `swapper_state` зберігається в БД: + ```sql + SELECT swapper_state FROM node_cache WHERE node_id = 'node-2-macbook-m4max'; + ``` + +--- + +## Налаштування для НОДА1 (Production Server) + +На НОДА1 guardian зазвичай запускається автоматично через docker-compose або systemd. + +### ENV змінні для НОДА1: + +```bash +NODE_ID=node-1-hetzner-gex44 +NODE_NAME=НОДА1 +NODE_ENVIRONMENT=production +NODE_SWAPPER_URL=http://swapper-service:8890 # Docker service name +NODE_ROUTER_URL=http://dagi-router:9102 # Docker service name +CITY_SERVICE_URL=https://daarion.space/api/city +``` + +--- + +## Архітектура + +``` +┌─────────────────┐ +│ Node Guardian │ (на кожній ноді) +│ (loop script) │ +└────────┬────────┘ + │ + ├─→ Swapper API (/health, /models) + ├─→ Router API (/health) + │ + ▼ +┌─────────────────┐ +│ City Service │ +│ /heartbeat │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ PostgreSQL │ +│ node_cache │ +└─────────────────┘ + │ + ▼ +┌─────────────────┐ +│ UI (Frontend) │ +│ Node Cabinet │ +└─────────────────┘ +``` + +--- + +## Важливі моменти + +1. **Один Router/Swapper для всіх нод у проді**: На НОДА1 є один DAGI Router та один Swapper Service, які обслуговують всі ноди. Guardian на кожній ноді просто збирає метрики та пушить їх в БД з правильним `node_id`. + +2. **ENV змінні мають пріоритет**: Guardian використовує `NODE_SWAPPER_URL` та `NODE_ROUTER_URL` з ENV, якщо вони встановлені. Це дозволяє для НОДА2 використовувати `localhost`, а для НОДА1 - Docker service names. + +3. **Heartbeat оновлює node_cache**: Кожен heartbeat оновлює метрики в `node_cache` для конкретного `node_id`, тому дані не перетираються між нодами. + diff --git a/docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md b/docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md new file mode 100644 index 00000000..dfcc36af --- /dev/null +++ b/docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md @@ -0,0 +1,183 @@ +# TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX — Router / Swapper / Node Guardian + +## Контекст + +- У DAARION.city є дві ноди: + - NODE1 — основний сервер (docker-compose, daji-router, swapper-service тощо). + - NODE2 — MacBook Pro M4 Max (`node-2-macbook-m4max`, IP: `192.168.1.33`). + +- UI `https://daarion.space/nodes/node/...` показує: + - Для NODE1: + - Swapper Service: `Degraded`, моделей 0/0, `No models found`. + - DAGI Router: `Up`, 9 агентів. + - Для NODE2: + - Swapper Service: `Degraded`, моделей 0/0, `No models found`. + - DAGI Router: `Down`, `Router недоступний`. + +- Cursor раніше змінював `get_node_endpoints` так, що: + - Для NODE1 використовувалися docker URLs (`http://dagi-router:9102`, `http://swapper-service:8890`). + - Для NODE2 — `http://localhost:9102`, `http://localhost:8890`, з визначенням по `node_id`. +- Це працює локально на Mac, але в прод-оточенні `city-service` крутиться в docker на NODE1, і `localhost` для нього — це контейнер, а не MacBook або DAGI Router. + +- Стани в UI беруться не напряму з Router/Swapper, а з таблиці `node_cache` (метрики, які пушить `node-guardian`). + +## Ціль + +1. Стандартизувати визначення endpoint'ів для DAGI Router і Swapper так, щоб: + - У PROD усе працювало через один базовий URL (docker-hostи `dagi-router` / `swapper-service`). + - Не було прив'язки до `node_id` для вибору URL. + - У DEV/локально на Mac можна було використовувати `localhost` через ENV. + +2. Переконатися, що `node-guardian` для NODE2 коректно оновлює `node_cache`: + - Є записи з `node_id = 'node-2-macbook-m4max'` для `router_healthy` і `swapper_state`. + - Помилки логуються явно, а не тихо ковтаються. + +3. Виправити відображення моделей у Swapper Service: + - Якщо в Swapper реально є моделі, UI має показувати їх (назву, статус, тип, тощо). + - Якщо моделей немає/немає зв'язку — показувати чесний `Degraded` з fallback, але не плутати це з "0/0 при наявних моделях". + +4. Мінімізувати магію та дублювання логіки: конфіг через ENV, один контракт між `city-service`, `node-guardian` і `DAGI Router / Swapper`. + +--- + +## Архітектурні принципи + +- **Один DAGI Router + один Swapper в проді** (на NODE1) обслуговує всі ноди. +- `city-service` завжди ходить до Router/Swapper через **базовий URL з ENV**, без умов по `node_id`. +- `node-guardian`: + - Викликає Router/Swapper. + - Перетворює результати в метрики `node_cache` (`router_healthy`, `swapper_state`, можливо інші). + - Маркує ці записи конкретним `node_id`, що відповідає ноді, де стоїть guardian. + +--- + +## Завдання + +### 1. Нормалізувати `get_node_endpoints` у `services/city-service/repo_city.py` + +1. Знайти реалізацію `get_node_endpoints`. +2. Прибрати логіку, яка підміняє URL на `localhost` на основі `node_id` (наприклад, `if "node-2" in node_id` тощо). +3. Замість цього: + - Винести базові URL у ENV, наприклад: + - `ROUTER_BASE_URL` (наприклад, `http://dagi-router:9102` у проді). + - `SWAPPER_BASE_URL` (наприклад, `http://swapper-service:8890` у проді). + - Для DEV (локальний запуск на Mac без Docker) дозволити дефолт: + - `ROUTER_BASE_URL=http://localhost:9102` + - `SWAPPER_BASE_URL=http://localhost:8890` +4. `get_node_endpoints(node)` має повертати структуру типу: + + ```python + return NodeEndpoints( + router_base=f"{ROUTER_BASE_URL}", + swapper_base=f"{SWAPPER_BASE_URL}", + # за потреби — окремі health / metrics / models endpoints + ) + ``` + +5. Не прив'язувати URL до `node_id`. Вся різниця між нодами має відображатись у: + * `node_cache` (метрики), + * БД агентів (який агент до якої ноди прив'язаний). + +### 2. Виправити `get_node_swapper_detail` у `services/city-service/routes_city.py` + +1. Переконатися, що endpoint `GET /api/v1/nodes/{node_id}/swapper`: + * Використовує `get_node_endpoints` для звернення до Swapper. + * Коректно обробляє: + * HTTP 200 з валідною відповіддю `/api/v1/models`. + * HTTP 5xx / timeout / connection error. +2. Логіка: + * Якщо відповідь успішна — парсити список моделей: + * Назва моделі. + * Статус (loaded/failed/loading). + * Кількість інстансів, GPU/CPU тощо (як дозволяє API Swapper). + * Оновлювати / читати кеш (`node_cache`) так, щоб UI міг показувати: + * Загальну кількість моделей. + * Loaded / Failed / Pending. + * Якщо помилка або моделі не повертаються: + * Повернути `status: "degraded"` та `models: []`, а НЕ 404. +3. Гарантувати, що UI завжди отримує валідний JSON: + * навіть якщо Swapper мертвий, + * без сирих трас і HTML помилок. + +### 3. Перевірити та поправити `node-guardian` (особливо для NODE2) + +1. Знайти код `node-guardian` (швидше за все окремий сервіс / скрипт). +2. Переконатися, що він: + * Читає ENV: + * `NODE_ID` (для NODE2: `node-2-macbook-m4max`). + * `CITY_API_URL` (HTTPS URL до city-service). + * Периодично: + * Викликає Router health endpoint (через `ROUTER_BASE_URL` або відповідний URL з ENV). + * Викликає Swapper `/api/v1/models` або health endpoint. + * Пушить у `node_cache` записи: + * `router_healthy` з payload (`{"ok": true/false, "latency_ms": ...}`). + * `swapper_state` з payload (`{"models_total": X, "models_loaded": Y, "models_failed": Z, "raw": ...}`). +3. Додати нормальні лог-меседжі: + * На успішне оновлення. + * На помилки (HTTP статус, текст помилки). +4. Перевірити, що в БД (таблиця `node_cache`): + * Після запуску guardian на NODE2 з'являються рядки з `node_id = 'node-2-macbook-m4max'` для `router_healthy` і `swapper_state`. + +### 4. Сумісність з наявними фільтрами по `node_id` + +1. Знайти всі місця, де читається `node_cache` для вузла: + * Наприклад, `get_node_status`, `get_node_swapper_detail`, `get_node_router_detail` тощо. +2. Переконатися, що фільтрація відбувається по `node_id` + `kind`: + * `WHERE node_id = :node_id AND kind = :kind` +3. Не використовувати глобальний `swapper_state` без `node_id`, якщо вже перейшли на модель "по нодах". +4. Якщо історично був один глобальний запис без `node_id`: + * Міграція (якщо потрібно) — або прибрати цей запис, або задовольнитися тим, що UI читає тільки записи з конкретним `node_id`. + +### 5. Swapper models → UI + +1. Забезпечити, щоб бекенд повертав у UI-модель (DTO) для Swapper дані: + * `status` (`"ok" | "degraded" | "down"`). + * `models_total`. + * `models_loaded`. + * `models_failed`. + * `models` (масив з короткою інформацією по кожній моделі). +2. Перевірити, що фронт (Node detail page) читає ці поля й не падає, якщо масив `models` порожній. +3. Якщо потрібен stub/fallback — він має відрізнятись від реального "0 моделей при піднятому Swapper'і". + +--- + +## Acceptance Criteria + +1. **Endpoint конфіг**: + * У `.env` / docker-compose є: + * `ROUTER_BASE_URL` (у проді → `http://dagi-router:9102`). + * `SWAPPER_BASE_URL` (у проді → `http://swapper-service:8890`). + * `get_node_endpoints` не використовує `node_id` для визначення URL. + * У DEV-режимі локальний запуск на Mac використовує `localhost:9102/8890`. + +2. **NODE2 в UI**: + * На сторінці НОДА2: + * DAGI Router: + * Показує реальний статус (`Up/Down`) на основі `router_healthy` з `node_cache`. + * При живому Router статус `Up` без "Router недоступний". + * Swapper Service: + * Показує реальну кількість моделей (якщо вони є у Swapper). + * При проблемах — `Degraded`, але без 404/порожніх екранiв. + +3. **Node Guardian**: + * Guardian на НОДА2 працює, логі показують регулярні оновлення. + * У Postgres (таблиця `node_cache`) є останні записи: + * `node_id = 'node-2-macbook-m4max'`, `kind = 'router_healthy'`. + * `node_id = 'node-2-macbook-m4max'`, `kind = 'swapper_state'`. + +4. **Swapper models**: + * `curl /api/v1/models` повертає список моделей. + * UI Swapper Service для NODE1 показує ці моделі в таблиці (а не лише `0/0`). + * При зупиненому Swapper: + * UI показує `Degraded` або `Down`, але бекенд повертає валідний JSON з fallback. + +5. **Без регресій**: + * NODE1 продовжує показувати 9 агентів у DAGI Router. + * Інші частини `nodes` UI працюють як раніше (агенти, статуси, Node Guardian & Steward секція). + +--- + +## Пріоритет + +* Високий. Це критична частина UX для нод та діагностики стану DAGI в DAARION.city. + diff --git a/services/city-service/repo_city.py b/services/city-service/repo_city.py index 693db710..5f867503 100644 --- a/services/city-service/repo_city.py +++ b/services/city-service/repo_city.py @@ -3345,36 +3345,41 @@ async def get_node_metrics_current(node_id: str) -> Optional[Dict[str, Any]]: async def get_node_endpoints(node_id: str) -> Dict[str, str]: """ - Отримати URL endpoints для конкретної ноди. - Якщо в БД немає значень — підставляє дефолти на основі node_id. + Отримати URL endpoints для DAGI Router та Swapper Service. + + Використовує ENV змінні для базових URL (один для всіх нод у проді). + Якщо в БД є специфічні URL для ноди - використовує їх, інакше - ENV дефолти. + + ENV змінні: + - ROUTER_BASE_URL (default: http://dagi-router:9102 для проді) + - SWAPPER_BASE_URL (default: http://swapper-service:8890 для проді) + + Для DEV (локальний запуск на Mac): + - ROUTER_BASE_URL=http://localhost:9102 + - SWAPPER_BASE_URL=http://localhost:8890 """ pool = await get_pool() + # Get node-specific URLs from DB if exist row = await pool.fetchrow(""" SELECT router_url, swapper_url FROM node_cache WHERE node_id = $1 """, node_id) - # Determine defaults based on node_id - is_node2 = "node-2" in node_id.lower() or "macbook" in node_id.lower() + # Get base URLs from ENV (one for all nodes in production) + router_base = os.getenv("ROUTER_BASE_URL", "http://dagi-router:9102") + swapper_base = os.getenv("SWAPPER_BASE_URL", "http://swapper-service:8890") - if is_node2: - # NODE2 defaults (localhost or IP-based) - defaults = { - "router_url": "http://localhost:9102", - "swapper_url": "http://localhost:8890" - } - else: - # NODE1 defaults (Docker-based) - defaults = { - "router_url": "http://dagi-router:9102", - "swapper_url": "http://swapper-service:8890" - } + defaults = { + "router_url": router_base, + "swapper_url": swapper_base + } if not row: return defaults + # Use DB values if present, otherwise fallback to ENV defaults return { "router_url": row["router_url"] or defaults["router_url"], "swapper_url": row["swapper_url"] or defaults["swapper_url"] @@ -3863,7 +3868,11 @@ async def node_heartbeat( swapper_state = CASE WHEN $12::jsonb IS NOT NULL THEN $12::jsonb ELSE swapper_state - END + END, + router_healthy = COALESCE($13::boolean, router_healthy), + router_version = COALESCE($14, router_version), + router_url = COALESCE($15, router_url), + swapper_url = COALESCE($16, swapper_url) WHERE node_id = $1 """, node_id, @@ -3877,7 +3886,11 @@ async def node_heartbeat( metrics.get("swapper_healthy"), metrics.get("swapper_models_loaded"), metrics.get("swapper_models_total"), - swapper_state_json + swapper_state_json, + metrics.get("router_healthy"), + metrics.get("router_version"), + metrics.get("router_url"), + metrics.get("swapper_url") ) return { diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index c785eae4..f77c2e04 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -4388,43 +4388,86 @@ async def get_node_swapper_detail(node_id: str): """ Get detailed Swapper Service status for a node. Used by Node Cabinet to show loaded models and health. - Returns fallback data if metrics not found (instead of 404). + + First tries to get data from node_cache (populated by node-guardian). + If not found, attempts direct call to Swapper API as fallback. + Returns fallback data if both fail (instead of 404). """ + import httpx + try: - # Fetch from node_cache + # First, try to fetch from node_cache (preferred - populated by node-guardian) metrics = await repo_city.get_node_metrics(node_id) - if not metrics: - # Return fallback instead of 404 - allows UI to show pending state - logger.info(f"Swapper metrics not found for {node_id}, returning fallback") + if metrics: + # Parse swapper state (stored as JSONB) + state = metrics.get("swapper_state") or {} + models_data = state.get("models", []) + + models = [ + SwapperModel( + name=m.get("name", "unknown"), + # Swapper uses "status": "loaded" not "loaded": true + loaded=m.get("status") == "loaded" or m.get("loaded", False), + type=m.get("type"), + vram_gb=m.get("size_gb") or m.get("vram_gb") + ) + for m in models_data + ] + return NodeSwapperDetail( node_id=node_id, - healthy=False, - models_loaded=0, - models_total=0, - models=[] + healthy=metrics.get("swapper_healthy", False), + models_loaded=metrics.get("swapper_models_loaded", 0), + models_total=metrics.get("swapper_models_total", 0), + models=models ) - - # Parse swapper state (stored as JSONB) - state = metrics.get("swapper_state") or {} - models_data = state.get("models", []) - models = [ - SwapperModel( - name=m.get("name", "unknown"), - # Swapper uses "status": "loaded" not "loaded": true - loaded=m.get("status") == "loaded" or m.get("loaded", False), - type=m.get("type"), - vram_gb=m.get("size_gb") or m.get("vram_gb") - ) - for m in models_data - ] + # Fallback: try direct call to Swapper API + logger.info(f"Swapper metrics not found in cache for {node_id}, trying direct API call") + endpoints = await repo_city.get_node_endpoints(node_id) + swapper_url = endpoints.get("swapper_url") + if swapper_url: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + # Try to get models from Swapper (endpoint: /models, not /api/v1/models) + resp = await client.get(f"{swapper_url}/models") + if resp.status_code == 200: + data = resp.json() + models_list = data.get("models", []) if isinstance(data, dict) else data + + models = [ + SwapperModel( + name=m.get("name", "unknown"), + loaded=m.get("status") == "loaded" or m.get("loaded", False), + type=m.get("type"), + vram_gb=m.get("size_gb") or m.get("vram_gb") + ) + for m in models_list + ] + + loaded_count = sum(1 for m in models if m.loaded) + + logger.info(f"✅ Direct Swapper API call successful: {loaded_count}/{len(models)} models loaded") + + return NodeSwapperDetail( + node_id=node_id, + healthy=True, + models_loaded=loaded_count, + models_total=len(models), + models=models + ) + except Exception as api_error: + logger.warning(f"Direct Swapper API call failed for {node_id} at {swapper_url}: {api_error}") + + # Final fallback: return empty state + logger.info(f"Swapper data unavailable for {node_id}, returning fallback") return NodeSwapperDetail( node_id=node_id, - healthy=metrics.get("swapper_healthy", False), - models_loaded=metrics.get("swapper_models_loaded", 0), - models_total=metrics.get("swapper_models_total", 0), - models=models + healthy=False, + models_loaded=0, + models_total=0, + models=[] ) except HTTPException: raise