hermes-hub/docs/web-api/CONTRACT.md

23 KiB
Raw Blame History

Контракт веб-интерфейса Hermes Hub

Версия контракта: 1.4 Дата: 2026-08-24

Этот документ — единственный источник истины для двух сторон: серверной (задание A15) и клиентской (A16). Обе стороны разрабатываются параллельно и до слияния друг друга не видят.

Правило, купленное дорого: в прошлом раунде служба обнаружения моделей была написана правильно, но названа model_discovery_service.py, тогда как потребитель импортировал model_discovery. Импорт был обёрнут в except ImportError, поэтому расхождение не дало ошибки — выбор моделей просто остался пустым навсегда. Здесь то же самое повторится в виде «страница грузится, но данных нет», если стороны разойдутся хоть в одном имени.

Поэтому: любое отклонение от этого документа — дефект, даже если код работает. Меняется контракт — сначала правится документ и согласуется с обеими сторонами.


1. Стек и его обоснование

Сервер: FastAPI + uvicorn. Обе зависимости уже объявлены в pyproject.toml в группе legacy — она осталась от удалённого gui_server.py и сейчас не используется ничем. Переименовать группу в web и подключить.

Клиент: обычный JavaScript и fetch, без сборки, без npm, без фреймворка.

Обоснование, а не вкусовщина. Проект ведут агенты на трёх разных машинах; любой шаг сборки означает согласование версий Node, установку зависимостей и дрейф lock-файлов между машинами. Интерфейс и так был единственным узким местом всех прошлых раундов. Страница, которую можно открыть файлом и отладить в браузере без инструментов, снимает целый класс проблем. React в этом проекте отклонён и раньше — по той же причине.

2. Границы и структура разделов

Веб-интерфейс дополняет десктоп, а не заменяет его немедленно. Десктопное приложение обязано продолжать работать до тех пор, пока веб не достигнет паритета. Ничего из router/ui/** не удаляется в рамках этих заданий.

Веб-клиент организован в 7 функциональных разделов (в порядке меню навигации):

  1. overview (Обзор) — Сводные счетчики слотов и подключений провайдеров, схема маршрутизации и выбор моделей ролей прямо на схеме.
  2. accounts (Аккаунты) — Список подключенных профилей, статусы авторизации и оперативные квоты.
  3. routing (Маршрутизация) — Главный центр управления: перетаскивание узлов Drag-and-Drop, выбор модели на узле, добавление (+ Добавить) и удаление (✕) профилей из цепочек.
  4. analytics (Аналитика) — Телеметрия вызовов и задержек за 24ч (p50/p95/max), пояснение fast-fail и честное отображение неподключенных провайдеров.
  5. health (Состояние) — Готовность системы, мониторинг системных ресурсов и рисков.
  6. logs (Журнал событий) — Обратный хронологический журнал событий Hermes Hub.
  7. settings (Настройки) — Параметры веб-сервера, токены, интервалы и системные пути.

Веб-слой живёт в пакете:

src/antigravity_provider/router/web/
    __init__.py
    server.py          # FastAPI-приложение и запуск uvicorn
    api.py             # эндпоинты
    security.py        # привязка к адресу, токен
    static/
        index.html
        app.js
        style.css

3. Безопасность — обязательная часть, не опция

Веб-интерфейс будет работать на сервере, доступном по сети. Требования:

  1. По умолчанию слушать только 127.0.0.1. Другой адрес — только явным параметром запуска, и при этом обязателен токен.
  2. Токен передаётся заголовком X-Hub-Token, сравнивается через secrets.compare_digest. При запуске без токена на не-локальном адресе сервер отказывается стартовать с внятным сообщением, а не поднимается открытым.
  3. Секреты не покидают сервер ни при каких условиях. Проверено на живых данных: сериализованный снапшот не содержит ни access_token, ни refresh_token, ни api_key, ни JWT. Это состояние обязано сохраниться — на стороне сервера нужен тест, который падает при появлении в ответе любого из этих ключей.
  4. Почты аккаунтов в снапшоте присутствуют открытым текстом (43 вхождения на машине владельца). Это персональные данные, не учётные. Маскирование — решение владельца; по умолчанию отдаём как есть, поскольку интерфейс без идентичности аккаунта бесполезен.
  5. Никаких секретов в URL и параметрах запроса.

4. Эндпоинты

GET /api/snapshot

Возвращает всё состояние интерфейса одним объектом.

Формат — точный результат dataclasses.asdict(HubSnapshot) с сериализацией datetime в ISO-8601. Проверено исполнением: снапшот сериализуется одним вызовом, объём около 100 КБ.

Эталонный пример лежит рядом: docs/web-api/snapshot.example.json — снят с живой машины владельца, почты замаскированы. Клиентская сторона разрабатывается против этого файла и до готовности сервера в нём не нуждается.

Ключи верхнего уровня:

generation, seq, timestamp, profiles_by_provider, all_profiles,
readiness, agents, providers, routing, quotas, metrics, is_stale

Обновление схемы ProfileViewModel: В рамках задачи A17 добавлены новые поля и состояния для отслеживания честного статуса:

  • health_state может принимать значение not_tested, если профиль никогда не проверялся.
  • Добавлено поле last_success_at (время последней успешной проверки, %H:%M:%S).

Ответ: 200 и JSON. При ошибках сборки — 503 с телом {"error": "<описание проблемы>"}.

POST /api/action

Единая точка для всех действий. Тело:

{ "action": "<имя>", "data": { ... } }

Имена действий берутся ровно из общего слоя action_handler.py:

account_details   add_account       agent_settings    apply_update
assign_role       auto_assign_all   check_updates     delete_credentials
edit_route        get_update_status oauth             open_routing
refresh_account   refresh_all       refresh_data      refresh_models
reorder_chain     save_chain        save_settings     set_main
set_model         set_orchestrator  test

Ответ:

{ "ok": true,  "message": "<текст для пользователя>", "data": { ... } }
{ "ok": false, "message": "<причина отказа по-русски>" }

ok: false — это 200, а не 4xx. Отказ действия — нормальный результат, а не ошибка протокола. 4xx остаётся для неизвестного действия и непройденной авторизации.

  • check_updates / apply_update / get_update_status (A27): проверка и установка обновлений из самой программы. check_updates опрашивает GitHub API релизов основного репозитория ochenstarik-ui/hermes-hub и сравнивает установленный коммит со сборкой последнего релиза. apply_update загружает установщик/пакет, проверяет sha256 по checksums.txt и запускает обновление с сохранением пользовательских данных и настроек.
  • save_chain / reorder_chain / edit_route (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. data: {"role_id": "<role_id>", "chain": ["<pid1>", "<pid2>", ...]}. Сохраняет конфигурацию в router_profiles.yaml через AutoAssigner.persist_role_chain.
  • assign_role: назначение профиля на роль. data: {"profile_id": "<pid>", "role_id": "<role_id>", "is_primary": true|false}. Выполняется через AutoAssigner.assign_profile_to_role.
  • open_routing и account_details в вебе — навигация, состояние держит клиент; сервер на них отвечает ok: true без побочных эффектов.
  • refresh_models (добавлено в A23): принудительное обновление списка моделей провайдера. data: {"provider": "<id>"}. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом.

start_device_auth / poll_device_auth (добавлено при разборе жалобы «не даёт зайти в грок»): авторизация по коду устройства для Grok и OpenAI Codex прямо из веба. Раньше поток был только в десктопе, а веб-мастер показывал заглушку.

start_device_authdata: {"provider": "grok"|"openai-codex"}. Возвращает {"session_id", "url", "code", "profile_id"}. Адрес и код выдаёт провайдер; интерфейс их только отображает и не имеет права подставлять свои.

poll_device_authdata: {"provider", "session_id"}. ok: true со status: "pending" означает ожидание подтверждения; status: "completed" — аккаунт подключён; ok: false — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.

start_redirect_auth / submit_redirect_callback / poll_redirect_auth / cancel_redirect_auth (добавлено при разборе жалобы «не даёт добавлять аккаунты на линукс»): вход по ссылке для Antigravity и Claude.

Раньше веб-мастер утверждал, что вход через веб «невозможен», и отправлял в консоль по SSH, а GET /api/health отдавал для этих двух провайдеров жёстко вписанное supported: false. Утверждение неверно: ProfileOAuthSession.handle_manual_callback_url и ClaudeOAuthSession.handle_auth_code принимают вставленное вручную значение. Браузер нужен где угодно, а не на машине с Hub.

start_redirect_authdata: {"provider": "antigravity"|"claude", "profile_id"?}. Возвращает {"session_id", "url", "port", "redirect_uri", "profile_id", "paste_kind"}. paste_kind"url" (Antigravity кладёт код в адресную строку) или "code" (Claude показывает код на странице); подсказка в интерфейсе обязана различаться, иначе владелец ищет не то. Ссылку выдаёт провайдер, интерфейс не имеет права подставлять свою.

profile_id передаёт клиент, а не подбирает сервер. AutoAssigner.find_free_slot определяет занятость по файлу учётных данных, но agy на Windows хранит их в keyring — файла нет ни у одного слота, поэтому все считаются свободными и всегда возвращается первый. Вход затёр бы работающий аккаунт. Слот выбирает владелец из списка, построенного по снапшоту.

submit_redirect_callbackdata: {"session_id", "provider", "callback_url"}. Ошибки разбора (нет кода, чужой state) не завершают сессию: владелец переносит значение между машинами руками и легко промахивается, а ссылка остаётся годной. Конечны только отказ провайдера и отмена.

poll_redirect_authdata: {"session_id"}. status: "pending" — ожидание; "completed" — подключено; ok: false — конечный отказ. Закрытие слушателя по таймауту (20 минут) не конечный отказ: вставить адрес вручную можно и после него.

GET /api/health

{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}. Без авторизации — нужен для проверки, что сервер поднялся. Поле auth_flows содержит информацию о доступности потоков авторизации на сервере. Формат:

{
  "auth_flows": {
    "openai-codex": {"supported": true, "reason": "device-code"},
    "grok": {"supported": true, "reason": "device-code"},
    "antigravity": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"},
    "claude": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"}
  }
}

Веб-клиент обязан проверять это поле и не показывать неработающую кнопку, а предлагать обходной путь.

GET /api/events

Возвращает список недавних событий системы из EventLogService в обратном хронологическом порядке.

Параметры запроса (Query Params):

  • limit (int, необязательно, по умолчанию 50, максимум 200): количество событий;
  • category (string, необязательно): фильтрация по категории (account, quota, routing, auth, system).

Ответ:

{
  "events": [
    {
      "timestamp": "22:50:14",
      "category": "system",
      "message": "Успешная проверка подключения ag-w2 (gemini-3.1-pro-high) за 1.4s.",
      "details": "...",
      "level": "success"
    }
  ]
}

Секреты в события не попадают. На эндпоинт распространяется санитайзер sanitize_snapshot.


GET /api/settings

Возвращает текущие настройки из hub_settings.json и пути системы.

Секреты не отдаются: поле web_api_token скрыто; наружу отдаётся только флаг web_api_token_configured (true/false).

Ответ:

{
  "web_api_host": "127.0.0.1",
  "web_api_port": 5800,
  "web_api_token_configured": false,
  "theme": "system",
  "quota_refresh_interval_sec": 300,
  "hermes_home": "C:\\Users\\...\\.hermes",
  "config_dir": "C:\\Users\\...\\.hermes\\config",
  "log_file": "C:\\Users\\...\\.hermes\\logs\\hermes-hub.log"
}

Сохранение настроек выполняется через стандартное действие POST /api/action с action: "save_settings".


GET / и статика

Сервер обязан отдавать сам интерфейс, а не только API. Корневой маршрут возвращает static/index.html, каталог static/ монтируется целиком.

Пункт добавлен постфактум: в версии 1.0 контракт описал каталог static/ в структуре пакета, но не назвал, кто его отдаёт. Обе стороны выполнили написанное — API отвечал, файлы клиента лежали в репозитории — и всё равно не собрались: в браузере был 404. Ответственность за пропуск на авторе контракта, а не на исполнителях.

Урок общего свойства: в контракте между двумя сторонами недостаточно описать, что каждая делает. Нужно назвать, где именно они стыкуются и кто отвечает за стык.

5. Обновление данных

Первая версия — опрос: клиент запрашивает /api/snapshot раз в 5 секунд и при действиях пользователя.

Поле seq в снапшоте монотонно растёт. Клиент обязан игнорировать ответ с seq меньше уже применённого — иначе поздний ответ затрёт свежий. Этот дефект в десктопе уже ловили, повторять не нужно.

Server-Sent Events — следующий шаг, в эти задания не входит. Закладывать под них структуру не нужно; опрос заменяется без переделки клиента.

6. Честность данных — правило распространяется на веб целиком

Прежнее и главное правило проекта действует без исключений:

  • ни одного числа, идентификатора или названия модели без измерения;
  • нет данных — «Н/Д» и причина рядом, доступная пользователю. Причина уже приходит в снапшоте полем unavailable_reason;
  • отличать «данных нет» от «данные ещё грузятся». Сейчас это не различается, и владелец видел пустые карточки без объяснения — квота появляется только после фонового опроса. В вебе состояние загрузки обязано выглядеть как загрузка. Для этого в объект квоты (в quotas и связанных местах) добавлено поле is_loading: bool. Если is_loading == true, значит идёт опрос; если false и нет квот — значит данных действительно нет.

7. Что известно про Linux заранее

Проверено по коду, чтобы никто не выяснял это заново:

  • paths.py уже кроссплатформенный: при отсутствии LOCALAPPDATA уходит в ~/.hermes;
  • поиск CLI провайдеров уже готов: shutil.which("agy") or shutil.which("agy.exe");
  • восемь мест дублируют логику LOCALAPPDATA в обход paths.py и на Linux дадут неверные пути: hermes_hub_app, router_config (две штуки), launcher_bootstrap, model_discovery_service, ui/assets, agy_subprocess.

Авторизация на headless-сервере — ограничение, которое надо признать честно, а не обойти молча:

Провайдер Поток На сервере без экрана
OpenAI Codex device-code работает — код вводится с любой машины
Grok device-code работает
Antigravity redirect на localhost не работает — редирект придёт на машину пользователя, а не сервера
Claude redirect на localhost не работает

Для двух последних интерфейс обязан сказать об этом прямо и предложить обходной путь (проброс порта по SSH либо авторизация на десктопе с переносом профиля), а не показывать неработающую кнопку.