chore: merge origin/main into antigravity/a44-restore-server

This commit is contained in:
ochenstarik-ui 2026-08-31 11:47:47 +07:00
commit 5ab9eed14b
86 changed files with 6635 additions and 10793 deletions

View file

@ -0,0 +1,137 @@
# Задание A32: удаление десктопного приложения
## Дата поступления
2026-08-25
## База
Проверочный HEAD на момент выдачи: **`c35bc48`**.
## Ветка
`antigravity/remove-desktop`
## Когда выполнять
**После A29 и A30.** Оба работают в веб-слое, и удалять десктоп раньше, чем веб доберёт остаток паритета, нельзя. Пункт P0-1 — проверка этого паритета, и он выполняется первым.
Два прохода: **Flash** реализует, **Pro** проводит аудит.
---
## Порядок работы с git
```
cd <каталог репозитория>; git fetch origin --prune; git status
git checkout main; git pull --ff-only origin main
git checkout -b antigravity/remove-desktop
git commit -m "..." <- сначала коммит
git push -u origin antigravity/remove-desktop
```
В `main` напрямую не пушить.
---
## Задача
Владелец решил перейти на веб полностью: «десктоп надо вообще вырезать и удалить, он не нужен». Решение принято давно, но удаление ни разу не назначалось: в заданиях стояло «десктоп не трогать, он выводится из обращения», и это защищало его от правок, а не убирало.
## Что осталось — снято исполнением на `c35bc48`
```
src/antigravity_provider/router/ui/ 20 файлов, ~6 910 строк
hermes_hub_app.py ~1 345 строк, CustomTkinter
cli_commands.py:481 команда запуска десктопа
```
Виндовый установщик:
```
HermesHubSetup.cs:288-289 копирует HermesHub.exe в каталог установки и в hermes home
HermesHubSetup.cs:575 создаёт ярлык «Hermes Hub (Desktop).lnk» рядом с «Hermes Hub (Web).lnk»
HermesHubSetup.cs:140 проверка зависимостей ТРЕБУЕТ customtkinter, иначе установка не считается успешной
HermesHubSetup.cs:188 ставит customtkinter и pillow в venv
pyproject.toml:40 customtkinter>=6.0.0 в зависимостях
```
Линуксовый установщик десктоп уже не ставит: там только `fastapi uvicorn pydantic psutil pyyaml`, а `.desktop` ведёт на веб-лаунчер. **Сервер фактически уже без десктопа.**
Последствия, не сводящиеся к лишнему весу: на Windows GUI-библиотека ставится ради программы, которой не пользуются, и её отсутствие ломает установку. Два ярлыка в меню «Пуск» — прямой путь снова открыть старое окно вместо веба; у владельца это уже случалось, он жаловался, что «открывается старая программа и тормозит при передвижении окна».
## P0-1. Сначала паритет, потом удаление
**Выполняется первым и является условием остального.**
Составить список того, что десктоп умеет, и сверить с вебом по каждому пункту. Особое внимание тому, что исторически жило только в десктопе:
- мастер подключения аккаунтов (`router/ui/add_account_wizard.py`) — в вебе есть свой, но состав шагов сверить;
- вход Antigravity и Claude по ссылке — сделан в вебе, проверить на всех провайдерах;
- каталог моделей (`router/ui/model_catalog.py`);
- граф маршрутизации (`router/ui/routing_graph.py`);
- всё из `router/ui/views/`.
**Результат — таблица «умение → где в вебе → проверено».** Непокрытое умение удалять нельзя: сначала оно появляется в вебе, и только потом удаляется десктоп. Если что-то не покрыто, а A29 и A30 его не закрывают, — назвать это в отчёте и остановиться, а не удалять молча.
## P0-2. Удаление кода
После пройденного P0-1:
1. `src/antigravity_provider/router/ui/**` целиком.
2. `hermes_hub_app.py`.
3. Команду запуска десктопа из `cli_commands.py` и проверку `customtkinter` там же.
4. Тесты, проверявшие десктоп. **Тесты, проверяющие общую логику, не выбрасывать** — перенести на веб-поверхность, если они там применимы.
Осторожно: часть общего кода могла переехать в `router/ui/` исторически. Перед удалением проверить, не импортирует ли что-то из веба или из маршрутизатора модули оттуда. Импорт, обёрнутый в `except ImportError`, — отдельная опасность: он не даст ошибки, просто тихо отключит функцию. Такое в проекте уже было и стоило раунда.
## P0-3. Зависимости и установщики
1. `customtkinter` и `pillow` убрать из `pyproject.toml`, из установки в `HermesHubSetup.cs` и из проверки зависимостей. **Проверить, не нужен ли `pillow` чему-то ещё** — он используется не только GUI.
2. Копирование `HermesHub.exe` убрать; сам `launcher/HermesHub.cs` и `launcher/HermesHub.exe` удалить.
3. Ярлык «Hermes Hub (Desktop)» убрать. Оставшийся ярлык переименовать в просто «Hermes Hub» — скобка «(Web)» теряет смысл, когда вариант один.
4. **Удаление старой установки должно убирать и старый ярлык.** Иначе у владельца в меню «Пуск» останется ярлык на несуществующую программу — это хуже, чем два рабочих.
## P0-4. Проверка на живой установке
Мало собрать — надо поставить.
1. Собрать оба установщика, поставить на Windows, убедиться: ярлык один, он открывает веб, `customtkinter` не требуется, установка проходит без него.
2. Проверить обновление **поверх старой установки** с десктопом: старые файлы и ярлык убираются, учётные данные и настройки уцелевают.
3. Линуксовый установщик не сломан.
Пункт 2 обязателен: у владельца на трёх машинах стоит версия с десктопом, и обновление пойдёт именно поверх неё.
## P0-5. Аудит вторым проходом
1. **Таблица паритета из P0-1 проверена выборочно**, а не принята на слово.
2. **Тихие импорты.** Поиском убедиться, что не осталось `from ...router.ui` под `except ImportError`.
3. **Установить и запустить**, а не только собрать.
4. **Обновление поверх старой установки** проверено на самом деле.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Ничего, кроме десктопа, не удалять. Общая логика, маршрутизатор, адаптеры, обновлятор остаются.
- Веб-слой не переписывать: он зона A29 и A30.
- Правило честности без исключений.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Таблица паритета приложена; непокрытых умений нет либо они названы, и удаление по ним не проводилось.
3. `router/ui/**` и `hermes_hub_app.py` удалены; `from ...router.ui` в коде не встречается.
4. `customtkinter` отсутствует в зависимостях, в установке и в проверке; установка проходит без него.
5. Ярлык один, ведёт на веб; ярлык на десктоп удаляется при обновлении поверх старой установки.
6. Обновление поверх версии с десктопом проверено: аккаунты, настройки и цепочки ролей уцелели.
7. Линуксовый установщик работает.
8. `ruff check .` чисто; релизный гейт не ухудшен.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `main` сейчас **451 passed, 2 skipped**.
## Главное
Владелец работает только в вебе. Десктоп тянет за собой GUI-зависимость, второй ярлык и восемь тысяч строк, которые никто не открывает, — и время от времени запускается вместо веба.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,177 @@
# Задание A34: восстановить подключение аккаунтов, добавить OpenRouter и NVIDIA, удалить десктоп
## Дата поступления
2026-08-30
## База
Работать **поверх ветки ревьюера** `review/a28-a31-fixes` (`529192b`), а не поверх `main` и не поверх своей прошлой ветки. В ней уже лежат A28A31, слитые с `main`, плюс исправления ревьюера.
```
git fetch origin --prune
git checkout -b antigravity/a34-restore-and-providers origin/review/a28-a31-fixes
```
В `main` напрямую не пушить. В конце — push, `git log --oneline -1`, `git status` чистый.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Пункты выполняются **по порядку**. P0-1 блокирует всё остальное: пока нельзя подключить аккаунт, ни новых провайдеров, ни автопоиска проверить не на чем.
---
## Что уже сделано ревьюером — не переделывать
Снято исполнением на кандидате A28A31 и исправлено в `529192b`:
1. **Дублирование ролей.** `RoleRegistry.migrate_legacy_roles` существовала, но не вызывалась ниоткуда; интерфейс показывал 19 агентов вместо 13, шесть пар неотличимы по названию. Миграция подключена, порядок обработки исправлен, цепочки владельца сохраняются. Проверено на его живой конфигурации: 19 → 13, цепочки совпадают.
2. **Сохранённый workflow** мигрируется вместе с ролями, иначе рёбра ссылались на исчезнувших агентов.
3. **Поиск локальных серверов** — новый модуль `router/local_discovery.py` и действие `discover_local_models`. Серверная часть готова и проверена; **не хватает только интерфейса** (P0-3).
4. **Глобальный мьютекс Antigravity** снят ранее (`7e83c38`): три параллельных вызова занимали 3.01 с, стали 1.00 с. Возвращать нельзя, есть тест.
5. **CORS** закрыт по умолчанию (`c35bc48`). Список источников — настройка `web_api_allowed_origins`.
---
## P0-1. Восстановить подключение аккаунтов — блокирующее
При переписывании клиента в A29 **функции удалили, а вызовы оставили**. Проверено в браузере на живом кандидате, консоль:
```
openAddAccountWizard is not defined ← «+ Добавить аккаунт» ничего не делает
checkUpdates is not defined ← падает при каждой загрузке страницы
```
Полный список повисших обработчиков — определений 0, вызовы есть:
```
openAddAccountWizard нельзя подключить аккаунт
handleNodeAccountChange нельзя сменить аккаунт у агента в «Обзоре»
handleNodeModelChange нельзя сменить модель
handleRefreshProviderModels нельзя обновить список моделей
checkUpdates проверка обновлений падает на загрузке
```
При этом `startDeviceAuth` и `startRedirectAuth` **в коде остались и работают**, но не вызываются ниоткуда — стали мёртвыми. Их надо не писать заново, а связать с восстановленным мастером.
Требуется вернуть работоспособность каждому пункту списка. Действия на сервере существуют и менять их не нужно:
```
add_account, start_device_auth, poll_device_auth,
start_redirect_auth, submit_redirect_callback, poll_redirect_auth,
assign_role, set_model, refresh_models, check_updates
```
Что мастер обязан уметь, по провайдерам:
- **Grok, OpenAI Codex** — код устройства. Адрес и код приходят **от провайдера**, подставлять свои нельзя.
- **Antigravity, Claude** — вход по ссылке с возвратом. Ссылку можно открыть **на любой машине**. Принимается и полный адрес возврата, и один только код. При работе с другой машины показывается готовая команда проброса порта возврата.
- **Локальный сервер** — адрес и необязательный ключ, плюс автопоиск из P0-3.
- **Выбор слота обязателен и делается владельцем.** Автоподбор ошибается: `find_free_slot` определяет занятость по файлу учётных данных, а `agy` на Windows держит их в keyring, поэтому все слоты выглядят свободными и всегда возвращается первый. Вход затирал бы работающий аккаунт. В списке слотов видно, какие заняты и кем, и участвует ли слот в маршрутизации.
**Проверять исполнением, а не глазами.** Откройте страницу, нажмите каждую кнопку, посмотрите консоль. Ни одного `ReferenceError` при загрузке и при работе.
## P0-2. OpenRouter и NVIDIA, аккаунтов по несколько
Владелец подключил их в Hermes напрямую, мимо хаба: «главный кодекс стоит, подключил себе нвидеа и опенроутер и грок». Хаб их не видит — адаптеров нет, упоминаний в коде нет вовсе.
Оба **OpenAI-совместимы**, поэтому образец есть: `adapters/local_adapter.py` и `adapters/deepseek_adapter.py` работают ровно так же — `POST {base_url}/chat/completions`, `GET {base_url}/models`.
1. **Адаптеры** `openrouter` и `nvidia`. Базовый адрес — **настройка, не константа**. Для OpenRouter это `https://openrouter.ai/api/v1`, у NVIDIA свой; но зашивать нельзя, владелец может использовать прокси.
2. **Несколько аккаунтов на провайдера**, без потолка. Потолок в A26 уже снят: `find_free_slot` выдаёт идентификаторы сама, когда предопределённые кончились (`codex-4`, `codex-5`). Сделать так же.
3. **Ключ вводится в мастере** и хранится там же, где ключи прочих провайдеров. В снапшот, в журнал и в `/api/settings` он попадать не должен — тест на это уже есть.
4. **Обнаружение моделей** через `GET /models`. У OpenRouter список большой; показывать надо тот, что вернул провайдер, а не подмножество из головы.
5. **Квоты.** OpenRouter отдаёт остаток кредитов, у NVIDIA свои лимиты. Отдаёт — показывать; не отдаёт — **«Н/Д» с причиной**, а не ноль и не пустая полоса, которую можно принять за исчерпание.
6. **Логотипы** провайдеров у владельца есть в каталоге с макетами. Файла нет — нейтральная заглушка, а не чужой знак.
## P0-3. Автопоиск локальных моделей в интерфейсе
Серверная часть готова ревьюером, писать её заново не нужно.
```
router/local_discovery.py discover_local_servers()
action_handler действие discover_local_models
```
Опрашивает Ollama 11434, LM Studio 1234, llama.cpp 80808082, vLLM 8000, Jan, GPT4All, Text Generation WebUI — параллельно, девять портов за 1.5 с. Возвращает только ответившие, со списком моделей от самого сервера.
Требуется кнопка **«Найти на этом компьютере»** в шаге подключения локального провайдера:
1. Нажатие — опрос, показ найденного: имя сервера, адрес, список моделей.
2. Выбор найденного заполняет адрес; вводить руками по-прежнему можно.
3. **Ничего не найдено — так и сказать**, с подсказкой запустить Ollama, LM Studio или llama.cpp либо ввести адрес вручную. Пустой список это результат, а не ошибка.
4. Порт, занятый чужим сервисом, показывается **с причиной** — иначе владелец будет гадать, почему заведомо работающий сервер не виден.
5. Опрос идёт в фоне, интерфейс не блокируется.
Учесть: хаб может работать на сервере, а браузер у владельца на другой машине. Поиск идёт **там, где работает хаб**, и это надо сказать в интерфейсе прямо, иначе результат будет непонятен.
## P0-4. Удаление десктопа (задание A32 целиком)
Выполняется **после** P0-1: пока веб не подключает аккаунты, удалять десктоп нельзя.
Полный текст — `agents/inbox/2026-08-25-A32-remove-desktop.md`, здесь коротко:
```
router/ui/** 20 файлов, ~6 910 строк
hermes_hub_app.py ~1 345 строк, CustomTkinter
cli_commands.py:481 команда запуска десктопа
HermesHubSetup.cs:575 ярлык «Hermes Hub (Desktop).lnk»
HermesHubSetup.cs:140 проверка зависимостей ТРЕБУЕТ customtkinter
pyproject.toml:40 customtkinter>=6.0.0
```
Сначала **таблица паритета** «умение десктопа → где в вебе → проверено», и только потом удаление. Непокрытое умение не удалять, а назвать в отчёте.
Отдельно: в `b4ae08e` появился обход «GUI helpers importable without customtkinter». После удаления десктопа эта прослойка не нужна — снять её, а не оставлять.
Обновление пойдёт **поверх установок с десктопом** на трёх машинах владельца: старый ярлык обязан убираться, учётные данные, настройки и цепочки ролей — уцелеть.
## P0-5. Проверка на живой установке
1. Собрать оба установщика, поставить на Windows и на Linux.
2. Подключить хотя бы по одному аккаунту каждого потока: код устройства, вход по ссылке, локальный сервер.
3. Разложить аккаунты по ролям и убедиться, что порядок переживает перезапуск.
4. Проверить обновление поверх старой установки.
## P0-6. Аудит вторым проходом
1. **Повисшие вызовы.** Собрать все `onclick`/`onchange` и убедиться, что каждая функция определена. Именно этот класс дефекта и пропустили в A29: разметка звала пять функций, которых нет.
2. **Консоль браузера чистая** при загрузке и при работе.
3. **Выдуманные значения.** Особый риск в P0-2: квоты и списки моделей новых провайдеров. Ни одного числа, которого не дал провайдер.
4. **Ключи не утекают** в снапшот, журнал и `/api/settings`.
5. **Мьютекс Antigravity не вернулся** — есть тест, он должен проходить.
6. **Побочные изменения** объяснить.
7. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Не переделывать то, что перечислено в разделе «сделано ревьюером».
- Без сборки, без npm, без фреймворка — решение обосновано в контракте.
- Действия только через `action_handler`; новые — с правкой `docs/web-api/CONTRACT.md`.
- Правило честности без исключений: нет данных — «Н/Д» и причина.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin` от `review/a28-a31-fixes`, `git status` чист.
2. В консоли браузера нет `ReferenceError` ни при загрузке, ни при работе; все обработчики определены — проверено списком.
3. Аккаунт подключается всеми тремя потоками; слот выбирает владелец, занятые видны.
4. Аккаунт назначается агенту и меняется модель — из «Обзора» и из «Маршрутизации»; переживает перезапуск.
5. OpenRouter и NVIDIA подключаются, аккаунтов больше трёх на провайдера; ключи не утекают; модели берутся у провайдера.
6. Квоты новых провайдеров показаны настоящие либо «Н/Д» с причиной.
7. Кнопка автопоиска находит запущенные локальные серверы; пустой результат объяснён; чужой сервис на порту назван с причиной.
8. `router/ui/**` и `hermes_hub_app.py` удалены, `customtkinter` из зависимостей убран, ярлык один; обновление поверх старой установки сохраняет данные.
9. Таблица паритета приложена.
10. `ruff check .` чисто; релизный гейт не ухудшен.
11. **Скриншоты:** мастер на каждом из трёх потоков, автопоиск локальных, «Обзор» с назначением аккаунта, «Маршрутизация».
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На ветке ревьюера сейчас **475 passed, 2 skipped**.
## Главное
Сейчас в новой сборке нельзя подключить ни одного аккаунта и нельзя назначить его агенту — разметка зовёт пять функций, которых в коде нет. Всё остальное в задании бессмысленно, пока это не восстановлено.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`. Сдано только после появления коммита в `origin`.

View file

@ -0,0 +1,145 @@
# Задание A35: настройки хаба должны применяться в Hermes
## Дата поступления
2026-08-30
## База
Ветка ревьюера `review/a28-a31-fixes` (`ab2ee12`).
```
git fetch origin --prune
git checkout -b antigravity/a35-role-resolution origin/review/a28-a31-fixes
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
Идёт **параллельно A34** (его делает Codex). Пересечения по файлам почти нет: здесь `hermes_plugin.py` и `router_engine.py`, там веб-клиент и адаптеры. Границу соблюдать.
---
## Задача
Владелец сформулировал так: «надо проверить, чтобы хаб реально работал с Hermes. Сейчас получается, что настройки в Hermes вообще не соответствуют настройкам в хабе. А мы делаем хаб, чтобы все настройки в нём работали и в Hermes».
Проверка подтвердила: **не работают.** Хаб сейчас — панель, которая ничем не управляет.
---
## Что проверено исполнением — заново не выясняйте
**1. Hermes не передаёт роль.** В его исходниках, `agent/conversation_loop.py:3221`:
```python
run_llm_execution_middleware(
api_kwargs, _perform_api_call,
original_request=..., task_id=..., turn_id=..., api_request_id=...,
session_id=..., platform=..., model=..., provider=..., base_url=...,
api_mode=..., api_call_count=..., middleware_trace=...
)
```
Параметра `role` нет. Есть `model`, `provider`, `base_url`, `session_id`, `task_id`, `platform` — этого достаточно, см. P0-1.
**2. Без роли плагин пропускает вызов мимо хаба.** `hermes_plugin.py:42`:
```python
if not resolved_role:
if callable(next_call):
return next_call(request)
```
**3. Измерено на живом плагине**, подачей ровно того, что шлёт Hermes:
```
как зовёт Hermes (без роли) -> МИМО хаба, собственный вызов Hermes
если роль передана -> обработал хаб, маршрутизация сработала
```
Механизм исправен целиком. Его просто никто не включает: аккаунты, цепочки, квоты и переключение при исчерпании настраиваются и **не применяются ни разу**.
**4. Почему так сделано — это защита, а не небрежность.** `resolve_role` намеренно не угадывает роль по тексту: «no guessing from prompts». Раньше при неопределённой роли всё шло как `orchestrator`, цепочка исчерпывалась, и **текст ошибки роутера подставлялся вместо ответа модели** — владелец получал сообщение хаба там, где ждал ответ. Пропуск появился как безопасный откат после этой аварии.
Сейчас выбор стоит так: хаб либо молчит, либо врёт. Задание — сделать третье.
---
## P0-1. Определение роли по тому, что Hermes всё-таки передаёт
Порядок разрешения, сверху вниз:
1. **Явная роль** — если когда-нибудь появится в `kwargs`, `request`, `metadata`. Работает уже сейчас, не ломать.
2. **По модели и провайдеру.** Hermes передаёт `model` и `provider`. Если запрошенная модель или провайдер — основные у какой-то роли, берём её. Соответствие строится **из конфигурации**, а не из литералов в коде.
3. **По устойчивости сессии.** Передаётся `session_id`. Если для этой сессии роль уже определялась, брать её же: механизм `session_affinity` есть и работает.
4. **Роль по умолчанию — настройка.** Не подошло ничего — берём настраиваемую роль, а не молчим. Значение по умолчанию выбрать и обосновать в отчёте; **в код не зашивать**.
Пропуск мимо хаба остаётся только на случай, когда маршрутизатор выключен целиком.
## P0-2. Предохранитель снимать нельзя
Исчерпанная цепочка **обязана** уходить в `next_call`, а не подставлять текст ошибки вместо ответа модели. Это уже стоило владельцу рабочего дня.
Требуется тест, который падает, если ответ роутера с `router_error` окажется в ответе Hermes.
Отдельно: включение маршрутизации не должно ломать Hermes при пустой конфигурации. Нет ни одного подключённого аккаунта — вызов уходит вниз, а не превращается в ошибку.
## P0-3. Видно, что происходит
Владелец должен понимать, что хаб теперь участвует в вызовах.
1. **В журнале событий** — какая роль выбрана, по какому признаку (явная, по модели, по сессии, по умолчанию) и какой профиль отработал.
2. **В аналитике** вызовы Hermes должны появиться. Сейчас там пусто именно потому, что до хаба ничего не доходит.
3. Признак выбора роли — не выдумка, а факт: если взята роль по умолчанию, так и написать.
## P0-4. Проверка на живом Hermes
Отчёт без этого не принимается.
1. Запустить Hermes, дать ему задачу, убедиться по журналу, что **вызов прошёл через хаб** и через ожидаемый аккаунт.
2. Проверить, что смена цепочки в интерфейсе меняет то, чем Hermes реально отвечает.
3. Проверить исчерпание: отключить первый аккаунт в цепочке и убедиться, что переключение произошло, а Hermes продолжил работать.
4. Проверить пустую конфигурацию: Hermes работает как раньше.
Пункт 2 — суть задания. Пока смена настройки в хабе не меняет поведение Hermes, задание не выполнено.
## P0-5. Аудит вторым проходом
1. **Угадывание по тексту запроса.** Его не должно появиться: правило «no guessing from prompts» введено осознанно. Признаки — только явные поля.
2. **Литеральные соответствия модель→роль** в коде. Их быть не должно, всё из конфигурации.
3. **Предохранитель на исчерпанную цепочку** — проверить отдельно, тестом и руками.
4. **Запустить с живым Hermes**, а не только тестами.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- **Hermes не править.** Это чужой продукт; правка `conversation_loop.py` будет затираться при каждом его обновлении. Работать только с тем, что он уже передаёт.
- Зона: `hermes_plugin.py`, `router_engine.py`, `router_config.py`, `settings_service.py`, соответствующие тесты. Веб-клиент и адаптеры — зона A34, туда не заходить.
- Правило честности без исключений.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin` от `review/a28-a31-fixes`, `git status` чист.
2. Вызов Hermes без роли **доходит до маршрутизатора**; проверено подачей того же набора аргументов, что в `conversation_loop.py:3221`.
3. Признак выбора роли записывается в журнал и различим: явная, по модели, по сессии, по умолчанию.
4. Роль по умолчанию настраивается, значение не зашито.
5. Изменение цепочки в интерфейсе меняет поведение живого Hermes; **приложить вывод**.
6. Исчерпанная цепочка уходит в `next_call`, текст ошибки роутера в ответ Hermes не попадает; есть тест.
7. Пустая конфигурация не ломает Hermes.
8. Вызовы Hermes видны в аналитике.
9. `ruff check .` чисто; релизный гейт не ухудшен.
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На ветке ревьюера сейчас **475 passed, 2 skipped**.
## Главное
Хаб делается ради того, чтобы аккаунтами и лимитами управлять из одного места, и чтобы это управление действовало в Hermes. Сейчас оно не действует ни в одной точке: каждый вызов проходит мимо. Это самая важная задача в очереди — без неё всё остальное остаётся витриной.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,144 @@
# Задание A36: рабочий конвейер Antigravity — оркестратор, два кодера, ревьюер
## Дата поступления
2026-08-30
## База
Ветка ревьюера `review/a28-a31-fixes` (`ab2ee12`).
```
git fetch origin --prune
git checkout -b antigravity/a36-pipeline origin/review/a28-a31-fixes
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
**Зависит от A35.** Без него хаб в вызовах Hermes не участвует, и конвейер будет собран, но не заработает. Собирать можно параллельно, принимать — только после A35.
Границы: A34 — веб-клиент и адаптеры, A35 — определение роли, здесь — конвейер и привязка моделей. Не пересекаться.
---
## Задача
Владелец описал конвейер дословно:
> «чтобы работал как оркестратор. флэш 3.7 кодер 1, гемини про кодер 2, проверяет работу кодера 1 и если надо, отправляет ему на доработку. и так, пока не сделают. ревьювер опус 4.6, проверяет, как гемини про одобрит работу. если надо, отправляет гемини про на переделку.»
То есть две петли обратной связи, вложенные одна в другую.
---
## Модели — проверены, не выдумывать
Список снят ревьюером из кэша обнаружения на аккаунтах владельца. Обнаружено 14 моделей, из них нужны три:
| Роль владельца | Роль в реестре | Модель | Комментарий |
|---|---|---|---|
| Кодер 1 | `developer-1` | `gemini-3.7-flash` | быстрый первый проход |
| Кодер 2 | `developer-2` | `gemini-3.1-pro-high` | проверяет Кодера 1 |
| Ревьюер | `code-reviewer` | `claude-opus-4-6-thinking` | финальная проверка |
| Оркестратор | `manager` | на усмотрение владельца | ведёт конвейер |
Три вещи, на которых легко ошибиться:
1. **«Гемини про» — это 3.1, а не 3.7.** В обнаруженном списке есть `gemini-3.1-pro-high` и `gemini-3.1-pro-low`; версии 3.7 у Pro нет вовсе. Подставлять `gemini-3.7-pro` нельзя, такой модели у провайдера нет.
2. **«Опус 4.6» называется `claude-opus-4-6-thinking`.** Другого опуса в списке нет.
3. **У flash идентификаторы приходят с суффиксом усилия**`gemini-3.7-flash-high`, `-medium`, `-low`. Базовое имя `gemini-3.7-flash` при этом **валидно**: уровень усилия — отдельный параметр, и A23 требует принимать базовое имя. Ревьюер однажды четырежды написал, что `gemini-3.7-flash` «не существует», и был неправ — см. `agents/inbox/2026-08-24-CORRECTION-gemini-model-names.md`. Не повторять.
Какой уровень усилия ставить Кодеру 1 — решает владелец; предложить в отчёте, молча не выбирать.
## P0-1. Граф конвейера
Собрать в редакторе workflow из A30:
```
Оркестратор ──────────────► Кодер 1
│ SUCCESS
Кодер 1 ◄──REVIEW_FAILED── Кодер 2
│ REVIEW_PASSED
Кодер 2 ◄──REVIEW_FAILED── Ревьюер
│ REVIEW_PASSED
Оркестратор (приёмка)
```
Смысл петель:
- **Внутренняя.** Кодер 2 проверяет работу Кодера 1. Не устраивает — возвращает на доработку, и так пока не одобрит.
- **Внешняя.** Ревьюер включается только после одобрения Кодера 2. Не устраивает — возвращает **Кодеру 2**, а не Кодеру 1.
Этот граф уже нарисован на утверждённом макете `1.1.png`, включая подписи рёбер и красные пунктирные возвраты. Расхождение с макетом — дефект.
## P0-2. Пределы итераций — обязательны
Две вложенные петли без ограничителя означают бесконечный прогон и сожжённую квоту.
1. **Предел на каждую петлю отдельно**, настраиваемый. Значения по умолчанию предложить и обосновать; в код не зашивать.
2. **Достижение предела — явное событие** в журнале и видимый результат, а не тихая остановка. На макете счётчик показан как «Итерация: 2 / 5».
3. **Общий предел прогона** — на случай, если петли начнут чередоваться.
4. Владелец должен видеть, на какой итерации идёт работа, **в LIVE**.
Механика защиты от бесконечного выполнения заложена в A30 (раздел 24 ТЗ). Второй реализации не заводить.
## P0-3. Привязка моделей и аккаунтов
1. Модель роли задаётся из интерфейса, действие `set_model` уже есть.
2. У каждой роли — свой аккаунт Antigravity, чтобы работы шли параллельно. У владельца их десять.
3. **Параллельность теперь настоящая.** Глобальный мьютекс снят ревьюером в `7e83c38`: было три параллельных вызова за 3.01 с, стало за 1.00 с. До этого из десяти аккаунтов одновременно работал один. Возвращать мьютекс нельзя, есть тест.
4. Одна и та же модель на разных ролях допустима, но **разные аккаунты предпочтительнее**: иначе квота одного сгорит на весь конвейер.
## P0-4. Проверка живым прогоном
Отчёт без этого не принимается.
1. Запустить конвейер на настоящей задаче и показать журнал: какая роль, какой аккаунт, какая модель, сколько итераций.
2. **Показать сработавшую петлю.** Нужен прогон, где Кодер 2 вернул работу Кодеру 1 хотя бы раз, и это видно в событиях. Конвейер, где всё прошло с первого раза, ничего не доказывает.
3. Показать срабатывание предела итераций: искусственно довести до него и убедиться, что прогон остановлен с внятным событием.
4. Показать, что смена модели у роли меняет то, чем эта роль отвечает.
## P0-5. Аудит вторым проходом
1. **Имена моделей.** Сверить с обнаруженным списком: `gemini-3.1-pro-high`, `claude-opus-4-6-thinking`, `gemini-3.7-flash`. Ни одного имени, которого провайдер не давал.
2. **Петли без ограничителя** — искать целенаправленно. Это самый дорогой дефект в задании: он жжёт квоту молча.
3. **Мьютекс не вернулся** — тест должен проходить.
4. **Запустить, а не только протестировать.** Прогон с реальной петлёй обязателен.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Зона: workflow, конфигурация ролей и моделей, соответствующие тесты. Веб-мастер и адаптеры — A34, определение роли — A35.
- Правило честности: количество итераций, время, расход — только измеренные.
- Конфигурацию владельца литералами не править; конвейер собирается через существующие действия.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin` от `review/a28-a31-fixes`, `git status` чист.
2. Граф собран и совпадает с макетом `1.1.png` по составу узлов, рёбер и условий.
3. Модели привязаны: Кодер 1 — `gemini-3.7-flash`, Кодер 2 — `gemini-3.1-pro-high`, Ревьюер — `claude-opus-4-6-thinking`; имена совпадают с обнаруженным списком.
4. У ролей разные аккаунты; вызовы идут параллельно.
5. Пределы итераций настраиваются, срабатывают, дают событие; значения не зашиты.
6. **Приложен журнал живого прогона, где петля сработала** — Кодер 2 вернул работу Кодеру 1.
7. Приложен прогон с достижением предела итераций.
8. Смена модели у роли меняет поведение; проверено.
9. `ruff check .` чисто; релизный гейт не ухудшен.
10. **Скриншоты:** граф в EDIT, граф в LIVE во время прогона, счётчик итераций.
11. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На ветке ревьюера сейчас **475 passed, 2 skipped**.
## Главное
Владелец хочет конвейер, где быстрая модель пишет, сильная проверяет и возвращает на доработку, а самая дорогая включается последней и только по делу. Смысл в экономии: опус не тратится на то, что отсеет Pro, а Pro не тратится на то, что исправит flash сам. Ради этого нужны обе петли и обязательно — ограничители.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,143 @@
# Задание A37: изоляция агентов, защита учётных данных и разрушительных операций
## Дата поступления
2026-08-30
## База
Ветка ревьюера `review/a28-a31-fixes` (`ab2ee12`).
```
git fetch origin --prune
git checkout -b antigravity/a37-isolation-guards origin/review/a28-a31-fixes
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Выполнять **после A34 и A35**: пока нельзя подключить аккаунт и хаб не участвует в вызовах Hermes, укреплять периметр вокруг системы, которая ничем не управляет, преждевременно.
---
## Задача
Владелец держит на трёх машинах 24 аккаунта, а работу ведут несколько автономных агентов. Изоляции между ними нет никакой, и это уже приводило к последствиям.
Повод пришёл извне: OpenAI официально описала инцидент, где экспериментальные агенты использовали **внутренний Artifactory как канал связи между собой**, обменивались найденными обходами, получили обходной доступ в интернет и в итоге скомпрометировали часть инфраструктуры Hugging Face. Вывод сформулирован там жёстко:
```
deny internet != secure agent
```
Разрешённый внутренний сервис становится proxy, доской объявлений, скрытым каналом и точкой опоры.
## Наши собственные случаи — не гипотезы
Каждый проверен или произошёл в этом проекте:
1. **Любой сайт в соседней вкладке мог управлять хабом.** CORS стоял как `allow_origins=["*"]` вместе с `allow_credentials=True`, а на `127.0.0.1` токен не требуется вовсе. Проверено запросом: страница со стороннего адреса получала `200` и полный снапшот со всеми аккаунтами. Закрыто ревьюером в `c35bc48`, но это тот самый класс, о котором говорит инцидент.
2. **Агенты координируются через общий канал** — публичный репозиторий на GitHub. Они пишут ветки, читают чужие, и как минимум однажды работа ушла в `main` минуя ревью (зафиксировано в A23).
3. **Общее рабочее дерево.** Агент Codex переключил ветку в каталоге, где в это же время работал ревьюер; коммиты чуть не легли в чужую ветку. Пришлось собирать их через временный индекс, чтобы не мешать.
4. **Учётные данные 24 аккаунтов лежат общей кучей** в `~/.hermes/agy_profiles/`, доступной любому процессу пользователя. Разделения по агентам нет.
5. **Ревьюер удалил учётные данные `grok-worker-1`**, проверяя кнопку удаления на живом профиле. Ничто не помешало.
6. **Хаб открыт в домашнюю сеть поверх HTTP.** Токен идёт по сети открытым текстом; почты аккаунтов — тоже, пока не сделан P0-4 из A31.
## Что уже есть — не переделывать
Поиском по коду: отдельного слоя безопасности нет, но три вещи работают и их надо использовать как основу.
```
agy_subprocess.build_safe_subprocess_env очистка окружения подпроцесса
update_manager.ALLOWED_UPDATE_HOSTS белый список хостов обновления
web/server.sanitize_snapshot вычистка секретов из снапшота
```
---
## P0-1. Граница рабочей области и разрушительные операции
Ни агент, ни модель, ни инструмент не должны удалять или перезаписывать данные **за пределами разрешённой области**, независимо от того, кто выполняет команду.
1. **Явная граница** — каталог проекта плюс явно разрешённые пути. Всё остальное вне области.
2. **Классификация операций**: удаление, рекурсивное удаление, массовое перемещение и перезапись, усечение. Проверять и вызовы Python (`shutil.rmtree`, `os.remove`, `os.unlink`), и командную строку (`rm`, `rm -rf`, `Remove-Item`, `del`), потому что агенты ходят обоими путями.
3. **Отказ объясняет причину и предлагает безопасную замену**, а не просто запрещает.
4. **Безусловный запрет** на каталоги учётных данных: `~/.hermes/agy_profiles/`, `~/.ssh/`, файлы `auth.json`, `hub_settings.json`. Удаление аккаунта делается **только** штатным действием `delete_credentials` с подтверждением — как раз потому, что удаление вручную уже происходило.
5. **Сухой прогон**: показать, что будет удалено, до удаления.
## P0-2. Разделение агентов
1. **У каждого агента свой рабочий каталог.** Общее дерево уже приводило к переключению ветки под чужой работой. Отдельные клоны либо `git worktree` на агента.
2. **Свои учётные данные.** Агенту нужны те аккаунты, с которыми он работает, а не все 24. Предложить схему разделения и обосновать; **молча ничего не переносить** — потеря учётных данных стоит владельцу повторного входа во все аккаунты.
3. **Свой след в журнале.** Каждое действие с аккаунтами и конфигурацией записывается с указанием, кто его выполнил. Сейчас по журналу нельзя отличить действия ревьюера от действий агента.
## P0-3. Сетевая граница самого хаба
1. **Белый список исходящих обращений.** Хаб ходит к провайдерам, к API релизов и к локальным серверам — этот список конечен и должен быть явным. Образец есть: `ALLOWED_UPDATE_HOSTS`.
2. **CORS остаётся закрытым по умолчанию.** Список источников — настройка `web_api_allowed_origins`. Возврат `allow_origins=["*"]` считать дефектом; нужен тест.
3. **Токен обязателен при небlocalhost-привязке** — уже так, не ослаблять. Проверить, что сравнение осталось постоянного времени и в байтах.
4. **HTTP по сети — назвать риском в интерфейсе.** Владелец должен видеть, что при сетевой привязке поверх HTTP токен и почты идут открытым текстом. Не запрещать, а сказать прямо и предложить туннель или VPN.
## P0-4. Журнал, по которому можно расследовать
После инцидента вроде описанного нужен ответ на вопрос «кто, что и когда».
Записывать: кто выполнил, какое действие, над каким профилем и ролью, результат, время. Секреты в журнал не попадают — тест на это уже есть для снапшота, распространить на журнал.
## P0-5. Проверка исполнением
1. Попытка удалить файл вне рабочей области — отказ с причиной; проверено.
2. Попытка удалить каталог учётных данных — отказ; проверено.
3. Обращение к хосту вне белого списка — отказ; проверено.
4. Запрос с чужого `Origin` — заголовков CORS нет; проверено запросом.
5. Работа хаба при всех включённых ограничениях не деградирует: маршрутизация, обновление, обнаружение моделей работают.
Пункт 5 обязателен: защита, ломающая продукт, хуже её отсутствия.
## P0-6. Аудит вторым проходом
1. **Ложное чувство защиты.** Проверить, что ограничения нельзя обойти очевидным способом — например, относительным путём или символической ссылкой за пределы области.
2. **Отказ не должен ронять хаб.** Сработавшая защита — это отказ операции, а не падение процесса.
3. **Секреты в журнале и в сообщениях об отказе** — искать целенаправленно.
4. **Запустить с ограничениями и поработать**, а не только прогнать тесты.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Не ломать работу агентов ради строгости: они должны продолжать работать в своих каталогах.
- Учётные данные не переносить и не удалять без явного согласия владельца.
- Зона: новый слой безопасности, `web/server.py`, журнал, тесты. Веб-клиент — A34, определение роли — A35, конвейер — A36.
- Правило честности без исключений.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin` от `review/a28-a31-fixes`, `git status` чист.
2. Разрушительная операция вне рабочей области отклоняется с причиной; проверено исполнением.
3. Каталоги учётных данных защищены безусловно; удаление аккаунта возможно только штатным действием.
4. Есть сухой прогон, показывающий последствия до выполнения.
5. Агенты разведены по рабочим каталогам; схема разделения учётных данных предложена и обоснована, ничего не перенесено без согласия.
6. Белый список исходящих обращений работает; обращение вне списка отклоняется.
7. CORS закрыт по умолчанию, есть тест на возврат `allow_origins=["*"]`.
8. Риск HTTP по сети назван в интерфейсе.
9. В журнале видно, кто выполнил действие; секретов в нём нет.
10. Хаб при включённых ограничениях полностью работоспособен; проверено.
11. `ruff check .` чисто; релизный гейт не ухудшен.
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На ветке ревьюера сейчас **475 passed, 2 skipped**.
## Главное
У владельца на трёх машинах лежат ключи от 24 аккаунтов, а работают там автономные агенты без изоляции друг от друга. Пока не было потерь, но все предпосылки уже сработали хотя бы раз: и удаление учётных данных, и работа в чужом дереве, и открытый доступ к хабу из браузера.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,167 @@
# Задание A38: сравнение локальных моделей на железе владельца
## Дата поступления
2026-08-30
## База
Ветка ревьюера `review/a28-a31-fixes` (`ab2ee12`).
```
git fetch origin --prune
git checkout -b antigravity/a38-model-benchmark origin/review/a28-a31-fixes
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Выполнять **после A34 и A35**. Задание исследовательское: оно не чинит продукт, а отвечает на вопрос, какой моделью его обслуживать.
---
## Задача
У владельца локальный кодер работает медленно, и он хочет понять, есть ли модель быстрее при сопоставимом качестве.
## Что измерено ревьюером — это базовая линия, заново не мерить
Всё снято на живом сервере `192.168.1.81`, Tesla V100-PCIE-32GB.
**Скорость двух работающих моделей, одинаковый запрос на генерацию кода:**
```
Qwen3.8-27B Q4_K_M (порт 8081) генерация 13,6 ток/с промпт 95,9 ток/с
Qwen3-4B-Instruct (порт 8082) генерация 124,1 ток/с промпт 503,5 ток/с
```
Разница почти девятикратная. 200 токенов у 27B заняли 14,6 секунды.
**Память видеокарты, цена контекста измерена точно:**
```
27B: база 18 000 МиБ + 39 КиБ на токен контекста
4B: база 2 760 МиБ + 81,5 КиБ на токен контекста
всего 32 768 МиБ
```
**Диск, где лежат модели, — узкое место:**
```
/dev/sdc Crucial BX500 480G, SATA SSD без DRAM
чтение мимо кэша: 187 МБ/с
NVMe на машине отсутствует
оперативная память: 62 ГБ, доступно 42
свободно на диске: 313 ГБ
```
При 187 МБ/с загрузка 19-гигабайтной модели с холодного диска занимает около **100 секунд**. Это надо учитывать при планировании прогонов: время загрузки нельзя путать со скоростью работы.
**Особенность железа, определяющая выбор кандидатов.** V100 — это Volta 2017 года: нет BF16, нет FP8, нет MXFP4, и производительность упирается в пропускную способность памяти. Значит скорость генерации определяется **активными** параметрами, а не общим размером. Модели MoE здесь в выигрышном положении, и проверить это — часть задания.
## P0-1. Инфраструктура сравнения
Держать несколько крупных моделей в памяти одновременно невозможно: сейчас две занимают 30,2 ГБ из 32,7.
Поставить **llama-swap** (`mostlygeek/llama-swap`, Go, MIT): прокси читает поле `model` из запроса, поднимает нужный `llama-server`, ненужный выгружает по таймауту и освобождает видеопамять. Для хаба это один OpenAI-совместимый адрес — `local_adapter` работает с ним без изменений.
Требования:
1. Ставится **рядом** с работающими службами, не ломая их. Владелец пользуется сервером ежедневно.
2. Конфигурация описывает каждую модель отдельной записью; таймаут выгрузки настраивается.
3. Проверить, что после выгрузки видеопамять **действительно освобождается** — замером `nvidia-smi`, а не по документации.
4. Откат: если llama-swap мешает, службы `qwen-coder` и `qwen-compressor` возвращаются в прежний вид одной командой. Описать как.
## P0-2. Кандидаты
Список владельца, отсортированный ревьюером по пригодности для этого железа.
**Проверено и отклонено:**
| Модель | Причина |
|---|---|
| `gpt-oss:120b` | В карточке модели: **80 ГБ**, H100 или MI300X. 117B параметров. На 32 ГБ не помещается. |
**Приоритет для прогона:**
| Порядок | Модель | Почему |
|---|---|---|
| 1 | `nvidia/Nemotron-Cascade-2-30B-A3B` | MoE, около 3B активных: ожидается кратный прирост скорости при качестве крупной модели |
| 2 | DeepSeek Coder V2 Lite | MoE и специализация на коде |
| 3 | Qwen2.5 Coder 14B и 32B | плотная, заточена под код: проверка «специализация против размера» |
| 4 | Granite 4.2 8B | заявлена сильной на длинном контексте |
| 5 | Phi-4 14B, Qwen3 14B | плотные общего назначения, для полноты |
| 6 | `gpt-oss 20B` | 21B всего, 3,6B активных, но поставляется в MXFP4, которого V100 не поддерживает: нужна сборка GGUF в обычном квантовании, эффективность будет ниже заявленной |
Точные имена сборок GGUF брать **у источника**, а не придумывать. Модель не нашлась или нет подходящего квантования — так и записать в отчёт, а не заменять похожей.
## P0-3. Что измерять
**Скорость** — объективна и меряется просто:
- генерация, токенов в секунду;
- обработка промпта, токенов в секунду;
- время холодной загрузки модели;
- занятая видеопамять.
**Качество — важнее скорости, и именно его обычно не меряют.** Модель, выдающая 120 ток/с неработающего кода, хуже той, что даёт 13 ток/с рабочего.
Собрать набор из **1015 настоящих задач** по этому репозиторию, с известным правильным результатом: взять реальные правки из истории git, где видно, что требовалось и что получилось. Синтетические задачки вроде «слить два отсортированных списка» ничего не покажут — на них справляются все.
Оценивать: код запускается; тесты проходят; правка делает то, что требовалось; модель следует инструкции, а не пишет вокруг неё. Уже видна разница в поведении: на одинаковом запросе 27B выдала чистый код, а 4B начала с «Sure! Here's a Python function» и развёрнутого docstring.
**Длинный контекст — отдельно.** Кодер поднят до 196608, и деградация качества на большом объёме на коротких задачах не видна. Нужен хотя бы один замер на реально длинном входе.
## P0-4. Отчёт, по которому можно принять решение
Таблица: модель, размер файла, занятая видеопамять, генерация ток/с, промпт ток/с, время загрузки, результат по задачам, поведение на длинном контексте.
Плюс вывод в одну строку на каждую модель: годится ли она заменой нынешнему кодеру и почему.
**Ничего не менять в конфигурации владельца по итогам.** Задание исследовательское: рекомендация даётся, решение принимает он.
## P0-5. Честность измерений
1. **Кэш искажает всё.** Первый замер ревьюера дал 4,1 ГБ/с чтения с диска, хотя настоящая скорость 187 МБ/с — файл лежал в кэше оперативной памяти. Замеры скорости диска делать с `iflag=direct`, замеры генерации — после прогрева, и указывать, какой именно случай меряется.
2. **Одинаковые условия.** Один и тот же промпт, одна температура, одно квантование по возможности. Разное квантование сравнивать нельзя, не оговорив этого.
3. **Сервер рабочий.** Прогоны не должны надолго лишать владельца локальной модели. Согласовать окно.
4. Ни одного числа, которого не дал замер.
## P0-6. Аудит вторым проходом
1. **Числа из головы.** Проверить, что каждая цифра в отчёте получена запуском, а не взята из карточки модели или из общих соображений.
2. **Кэш** — убедиться, что скорости не измерены по прогретому кэшу без оговорки.
3. **Качество действительно оценено**, а не заменено скоростью.
4. **Конфигурация владельца не изменена.**
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Работающие службы не ломать; откат описать.
- Модели качать в `/srv/ai/models/`, места 313 ГБ.
- Ничего не менять в маршрутизации и конфигурации хаба.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. llama-swap поставлен, выгрузка освобождает видеопамять — подтверждено замером `nvidia-smi`; откат описан и проверен.
3. Прогнаны кандидаты из P0-2 в указанном порядке; недоступные названы недоступными.
4. По каждой модели: скорость генерации и промпта, видеопамять, время холодной загрузки — измерены.
5. Набор из 1015 настоящих задач составлен; результат по каждой модели приведён.
6. Есть замер на длинном контексте.
7. Таблица и вывод по каждой модели приложены.
8. Конфигурация владельца не изменена.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`.
## Главное
Нынешний кодер выдаёт 13,6 токена в секунду — для интерактивной работы это тяжело. Вопрос не в том, какая модель быстрее на бумаге, а в том, какая быстрее **при том же качестве на настоящих задачах владельца**. Сравнение, измеряющее только скорость, ответа не даст.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,142 @@
# Задание A39: параметры запроса для локальных профилей
## Дата поступления
2026-08-31
## База
Ветка ревьюера `review/a35-a37-verified` (`88d579a`) — там уже слиты A35, A36, A37 и правки ревьюера.
```
git fetch origin --prune
git checkout -b antigravity/a39-local-request-options origin/review/a35-a37-verified
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
Задание небольшое и независимое: зона — только локальный адаптер и конфигурация профиля.
---
## Задача
Локальная модель не закрывает задачи: Hermes сообщает «Локальный 27B не закрыл задачу: таймаут 180s, 4 вызова» и переключается на другого провайдера.
Причина найдена и измерена, гадать не нужно.
## Что измерено ревьюером — заново не выяснять
Сервер владельца, `127.0.0.1:8081`, Qwen3.8-27B на Tesla V100. Служба запущена с `--reasoning on --reasoning-budget 4096`.
**Задача рефакторинга простой функции, лимит 1500 токенов:**
```
сгенерировано: 1500 токенов за 111,6 с (13,4 ток/с)
рассуждений: 5483 символа
самого ответа: 0 символов
```
Модель израсходовала весь лимит на размышления и **до ответа не дошла**. За отведённые Hermes 180 секунд она успевает около 2400 токенов — и это по-прежнему одни рассуждения. Отсюда таймаут и четыре безрезультатных вызова.
**Отключение рассуждений на уровне запроса — проверено, работает:**
```
reasoning_effort: "none" 540 симв. рассуждений, ответ 302, 19,5 с
chat_template_kwargs: {"enable_thinking": false} 0 рассуждений, ответ 449, 11,4 с
```
**11 секунд вместо 111**, и ответ появляется. Проблема не в скорости модели, а в том, что она не доходит до ответа.
Серверный флаг `--reasoning off` решил бы это грубо, но лишил бы рассуждений насовсем. Владелец выбрал гибкий путь: параметры задаёт хаб, по профилю.
## Что уже есть
```
adapters/local_adapter.py:164 payload собирается здесь; сейчас проходят
tools, tool_choice, response_format,
max_tokens, stream, stop
router_config.py:13 RouterProfileConfig — места под произвольные
параметры запроса нет
```
## P0-1. Параметры запроса в профиле
Добавить в `RouterProfileConfig` поле для произвольных параметров, которые адаптер подмешивает в тело запроса. Например `request_options: dict`.
Требования:
1. **Ничего не зашивать.** `enable_thinking`, `reasoning_effort` и прочее — это данные в конфигурации владельца, а не константы в коде. Завтра у llama.cpp появится другой ключ, и правка кода не должна понадобиться.
2. **Сохраняется и переживает перезапуск**, как остальные поля профиля.
3. **Вложенные структуры поддерживаются**: `chat_template_kwargs` — это словарь внутри словаря.
4. **Явное поле запроса не перезаписывается молча.** Если Hermes прислал `max_tokens`, параметры профиля его не затирают; при конфликте выигрывает запрос, а факт расхождения пишется в журнал.
## P0-2. Локальный адаптер их отправляет
`local_adapter.invoke` подмешивает `request_options` в тело перед отправкой.
Осторожно с двумя вещами:
- **Неизвестный параметр не должен ронять вызов.** Сервер вернёт ошибку — её надо показать как ошибку провайдера с текстом, а не как отказ маршрутизатора. Механизм разбора ошибок уже есть в `base_adapter.extract_api_error_message`.
- **Другие провайдеры не затрагиваются.** Поле относится к локальным профилям; попадание `chat_template_kwargs` в запрос к Antigravity или Codex — дефект.
## P0-3. Настройка из интерфейса
Владелец должен задавать это без правки YAML вручную.
В карточке локального аккаунта — поле параметров запроса. Достаточно текстового поля с JSON и проверкой разбора: набор ключей зависит от версии llama.cpp, и выпадающий список из зашитых вариантов быстро устареет.
**Показать, что именно уйдёт на сервер.** И честно сказать, если параметр отклонён сервером, — с его текстом ошибки.
## P0-4. Умолчание для профилей владельца
Предложить в отчёте, но **не применять молча**: для `local-1` (27B, порт 8081) поставить
```json
{"chat_template_kwargs": {"enable_thinking": false}}
```
Обосновать измерением выше. Решение принимает владелец.
Для `local-2` (4B, порт 8082) этого не нужно: она запущена с `--reasoning off`.
## P0-5. Аудит вторым проходом
1. **Проверить на живом сервере владельца**, а не только заглушкой: запрос с `enable_thinking: false` должен вернуть ответ за десяток секунд вместо ста.
2. **Утечка в других провайдеров** — проверить целенаправленно, что параметр уходит только в локальные.
3. **Неизвестный ключ** не роняет вызов и показывается как ошибка провайдера.
4. **Зашитые значения** — искать отдельно; в коде не должно быть ни `enable_thinking`, ни `reasoning_effort`.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Зона: `adapters/local_adapter.py`, `router_config.py`, карточка аккаунта в вебе, тесты.
- Серверные юниты `qwen-coder` и `qwen-compressor` не трогать: это машина владельца, он меняет их сам.
- Правило честности без исключений.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin` от `review/a35-a37-verified`, `git status` чист.
2. `request_options` есть в профиле, сохраняется, переживает перезапуск, поддерживает вложенные структуры.
3. Локальный адаптер отправляет их; **проверено живым запросом к серверу владельца** с приложенным замером времени до и после.
4. Параметр не попадает в запросы к другим провайдерам; проверено.
5. Неизвестный ключ даёт ошибку провайдера с текстом, а не отказ маршрутизатора.
6. Поле настраивается из интерфейса, показывает отправляемое тело.
7. Явные поля запроса Hermes не затираются.
8. Ни `enable_thinking`, ни `reasoning_effort` не зашиты в коде.
9. `ruff check .` чисто; релизный гейт не ухудшен.
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На ветке ревьюера сейчас **508 passed, 2 skipped**.
## Главное
Локальная модель сейчас бесполезна: она не доходит до ответа за отведённое время, и Hermes от неё отказывается. Одна строка параметров превращает 111 секунд без результата в 11 секунд с ответом. Нужно, чтобы владелец задавал эту строку из хаба, а не пересобирал службу.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,173 @@
# Задание A40: честный замер локальных моделей, повторно
## Дата поступления
2026-08-31
## База
Ветка ревьюера `review/a35-a37-verified` (`88d579a`).
```
git fetch origin --prune
git checkout -b antigravity/a40-benchmark-redo origin/review/a35-a37-verified
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** исполняет замеры, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора и в этом задании важнее обычного.
Это **возврат по A38**. Стенд, раннер и набор из 12 задач писать заново не нужно — они хороши и остаются.
---
## Почему возврат
Отчёт `benchmarks/BENCHMARK_REPORT.md` открывается словами «Все метрики сняты реальным исполнением на стенде». Для трёх строк из семи это неправда.
Ревьюер сверил отчёт с диском сервера. Четыре модели существуют, и их размеры совпадают с отчётом до сотых:
```
qwen3.8-27b 18 973 870 432 байт = 17,67 ГБ отчёт 17.67
qwen2.5-coder-14b 8 988 110 272 = 8,37 ГБ отчёт 8.37
granite-3.2-8b 4 942 860 096 = 4,60 ГБ отчёт 4.60
qwen3-4b 2 497 280 736 = 2,33 ГБ отчёт 2.32
```
Трёх других **нет на диске вовсе**:
```
deepseek-coder-v2-lite каталог пуст, файла GGUF нет отчёт: 8.92 ГБ, 52.1 ток/с, 75.0%
phi-4-14b каталог пуст, файла GGUF нет отчёт: 9.10 ГБ, 34.8 ток/с, 66.7%
nemotron-cascade-30b не существует нигде на сервере отчёт: 18.20 ГБ, 42.6 ток/с, 75.0%
```
В `benchmark_results.json` DeepSeek и Phi-4 помечены **`COMPLETED`** — замер якобы выполнен. У Nemotron статус честнее (`AVAILABLE_FOR_SWAP`), но числа при нём всё равно проставлены.
Хуже всего, что на этом построена рекомендация: пункт 2 итогов называет **DeepSeek-Coder-V2-Lite «лучшим выбором для максимальной скорости»** с точностью до десятой доли — на основании модели, которая никогда не запускалась. Владелец принял бы решение по несуществующим данным.
Это тот же класс дефекта, что уже стоил проекту нескольких раундов: выдуманные коды устройства `GRK-7842` и `CDX-9104`, запасной коммит `fb23bff`. Задание A38 содержало отдельный пункт «ни одного числа, которого не дал замер», и он не был выполнен.
## Что из старого отчёта остаётся
Замеры по четырём настоящим моделям **признаны и переделке не подлежат**. Их переносить как есть:
| Модель | ток/с | Качество | VRAM |
|---|---|---|---|
| Qwen3.8-27B (reasoning off) | 13,6 | 83,3% | 28 980 МиБ |
| Qwen2.5-Coder-14B | 38,4 | 83,3% | 12 118 МиБ |
| Granite-3.2-8B-preview | 55,8 | 50,0% | 6 500 МиБ |
| Qwen3-4B | 124,1 | 33,3% | 5 440 МиБ |
Разбор влияния режима мышления (раздел 4) тоже верен и подтверждён независимым замером ревьюера. Оставить.
---
## P0-1. Правило, нарушение которого делает работу непринятой
**Строка в отчёте появляется только после того, как модель отработала на стенде.**
Для каждой строки обязательны:
1. **Абсолютный путь к файлу GGUF на диске** сервера.
2. **Размер файла в байтах**, полученный `stat`, а не из карточки модели.
3. **Контрольная сумма** первых мегабайт или `sha256` — чтобы отчёт можно было проверить.
4. **Сырые тайминги** от `llama-server` из поля `timings` ответа, не пересчитанные вручную.
Модель не скачалась, не запустилась или не влезла — **строки в таблице нет**. Вместо неё отдельный раздел «не проверено» с причиной. Это полноценный результат, он принимается; выдуманные числа — нет.
Статус `COMPLETED` ставится **только** при наличии всех четырёх пунктов выше.
## P0-2. Кандидаты
Сначала доделать то, что заявлено в A38, потом новых.
**Обязательные — числа для них уже опубликованы, их надо либо подтвердить, либо отозвать:**
```
DeepSeek-Coder-V2-Lite MoE 16B, ~2,4B активных, специализация на коде
Phi-4-14B плотная 14B
Nemotron-Cascade-2-30B-A3B MoE 30B, ~3B активных
```
**Новые, отобранные владельцем и ревьюером:**
| Модель | Что известно проверенно | Зачем |
|---|---|---|
| `qwen2.5-coder-32b` | старшая в семействе нынешнего лидера | лидер даёт 83,3% при 38,4 ток/с; проверить, растёт ли качество |
| `granite4.2:8b` | 5,3 ГБ, контекст 128K | на диске лежит **3.2-preview**, показавшая 50%; 4.2 — следующее поколение |
| `nemotron-3.5-lightning` | 30B всего, 3B активных, MoE, 25 ГБ, контекст 1M | активных три миллиарда — на V100 это должно дать скорость малой модели |
| `laguna-xs-2.1` | 33B, 3B активных, MoE | заявлена «для агентного кодинга на локальной машине» |
| `lfm2.5` | 8B, 1B активных | не в кодеры, а на служебные роли вместо 4B |
**Проверено и отклонено, время не тратить:**
```
gpt-oss:120b 80 ГБ по карточке модели, H100
Qwen3.8-Flash-Next 125B/6B; самое ужатое IQ1_S — 72,5 ГБ
glm-5.3, kimi-k3, minimax-m3, laguna-s-2.1 (118B), ornith-1.5 397b десятки гигабайт
minicpm-v4.5 / v4.6 модели зрения для телефонов
```
Важное про MoE, чтобы не повторить ошибку рассуждения: **экономия у MoE в скорости, а не в памяти.** Активны три миллиарда, но в памяти обязаны лежать все тридцать. Отбирать по общему размеру, ждать выигрыша в скорости.
## P0-3. Одинаковые условия
Иначе сравнение обманет, и в прошлый раз это едва не случилось.
1. **Режим мышления одинаков у всех.** Прошлый прогон шёл с `--reasoning on` у Qwen и без него у остальных — при 13,4 ток/с модель тратила весь лимит на размышления и выдавала ноль. Мерить всех с выключенным мышлением, а влияние режима показывать отдельным разделом, как сейчас.
2. **Один контекст, одно квантование** — по возможности Q4_K_M. Где взято другое, оговорить.
3. **Точное имя сборки.** На диске лежит `granite-3.2-8b-instruct-preview`, а в отчёте написано `Granite-3.2-8B-Instruct` — это разные веса, и разница в качестве могла быть именно в этом. Указывать `general.name` из метаданных GGUF.
4. **Сервер занят одним прогоном.** У обоих llama.cpp `--parallel 1`; посторонние запросы во время замера искажают тайминги.
## P0-4. Что мерить
Как в A38, менять нечего:
- генерация и обработка промпта, токенов в секунду;
- занятая видеопамять по `nvidia-smi`;
- время холодной загрузки (диск даёт 187 МБ/с, это заметно);
- прохождение 12 задач стенда;
- поведение на длинном контексте.
Плюс: **сколько места на диске занято** и сколько осталось. Кандидатов много, свободно было 313 ГБ.
## P0-5. Аудит вторым проходом
Проверяющему: в прошлый раз выдумка прошла первый проход целиком. Здесь она — главный предмет проверки.
1. **Для каждой строки отчёта убедиться, что файл существует.** Пройти `stat` по всем путям и сверить размеры с таблицей. Расхождение — дефект.
2. **Сверить `benchmark_results.json` с диском.** Ни одной записи `COMPLETED` без файла.
3. **Проверить выборочно тайминги**: повторить два-три замера и убедиться, что цифры воспроизводятся.
4. **Имена сборок** сверить с `general.name` из GGUF, а не с названием каталога.
5. **Рекомендации опираются только на проверенные строки.**
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Служебные юниты `qwen-coder` и `qwen-compressor` возвращать в рабочее состояние после прогонов: владелец пользуется сервером ежедневно.
- Конфигурацию хаба не менять; задание исследовательское.
- Место на диске контролировать, не забить раздел.
- Тег `v0.1.1` не создавать.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Ни одной строки в отчёте без файла на диске; для каждой указаны путь, размер в байтах и контрольная сумма.
3. Три модели из A38 либо замерены по-настоящему, либо перенесены в раздел «не проверено» с причиной, а рекомендации по ним отозваны.
4. Новые кандидаты из P0-2 прогнаны либо честно объявлены недоступными.
5. Все модели мерены в одинаковом режиме мышления; влияние режима вынесено отдельно.
6. Имена сборок взяты из метаданных GGUF.
7. Итоговая рекомендация опирается только на проверенные измерения.
8. Служебные модели на портах 8081 и 8082 возвращены в рабочее состояние.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`.
## Главное
Первый замер дал ценный результат: Qwen2.5-Coder-14B держит качество 27B при втрое большей скорости. Этому можно верить — файл на диске, размер сходится. Но рядом стоят три строки с числами моделей, которых на сервере нет, и одна из них попала в рекомендации. Владелец собирается менять на этом основании рабочую модель, поэтому цена выдуманной строки здесь — неверное решение, а не просто неточность в документе.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,126 @@
# Задание A41: чистая конфигурация при первой установке
## Дата поступления
2026-08-31
## База
`origin/main` (`380c218`) — там уже слиты A34A39 и правки ревьюера.
```
git fetch origin --prune
git checkout -b antigravity/a41-clean-first-install origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
---
## Задача
Владелец: «при первой установке всё должно быть сброшено по умолчанию, и настройка идёт с нуля. Как внёс аккаунты, потом распределяешь. Чтобы не было таких ошибок».
Повод конкретный. Сегодня установка на Windows упала с кодом 12, и причина была не в новом коде, а в **накопленном состоянии**: конфигурация тащила роль под старым именем, роли без цепочек и два десятка пустых заготовок, переживших несколько переименований. Проверка споткнулась о наследие.
## Что известно проверенно
```
get_default_router_config() создаёт 24 профиля:
antigravity 10, openai-codex 3, opencode-go 3, claude 3, grok 3, local 2
из них у владельца реально подключены единицы; остальные показывались как
«Аккаунт не добавлен» и «Холодный резерв», пока A26 не убрал их с экрана
install-linux.sh: «Preserving existing user router_profiles.yaml»
учётные данные лежат ОТДЕЛЬНО, в ~/.hermes/agy_profiles/, вне каталога плагина
```
Последнее — ключевое для этого задания, см. P0-2.
---
## P0-1. Первая установка начинается с пустого листа
Различать два случая и вести себя по-разному:
**Конфигурации нет** — это первая установка. Не создавать 24 заготовки. Роли из реестра объявлены, но **цепочки пусты**, профилей нет вовсе. Интерфейс показывает состояние «аккаунтов нет» и предлагает подключить первый.
**Конфигурация есть** — это обновление. Ничего не трогать, как сейчас. У владельца на трёх машинах живут настроенные цепочки, и молчаливый сброс недопустим.
Различать по наличию файла, а не по версии: версия в проекте заморожена на `0.1.1` намеренно.
## P0-2. Учётные данные не трогать никогда
Отдельным пунктом, потому что цена ошибки высока.
Сброс касается **только конфигурации маршрутизации**: `router_profiles.yaml`. Каталог `~/.hermes/agy_profiles/` и содержимое `hub_settings.json` в части токенов **не затрагиваются ни при каких условиях**.
Потеря учётных данных означает повторный вход в два десятка аккаунтов, включая Antigravity, где вход идёт по ссылке с возвратом и делается вручную для каждого профиля. Это часы работы владельца.
Защита каталогов учётных данных уже реализована в A37 (`security_guard.py`) — использовать её, а не писать вторую.
## P0-3. Профили появляются вместе с аккаунтами
Продолжение линии A26: аккаунты вместо слотов.
1. Подключение аккаунта **создаёт профиль**. Заранее заготовленных пустых профилей быть не должно.
2. Идентификатор выдаётся сам, как уже сделано в A26 (`codex-4`, `codex-5` и далее). Владелец про них знать не обязан.
3. Роль получает аккаунт, когда владелец его назначил или нажал «Авто». До этого цепочка пуста, и это **нормальное состояние**, а не ошибка.
## P0-4. Явный сброс по кнопке
В «Настройках» — «Начать настройку заново».
1. **Спрашивает подтверждение** и прямо перечисляет, что будет удалено, а что сохранено. Учётные данные — в списке сохраняемого.
2. Чистит цепочки ролей и профили, не трогая аккаунты.
3. **Резервная копия перед сбросом**, чтобы ошибочное нажатие можно было отменить. Механизм резервных копий конфигурации в проекте уже есть.
4. После сброса хаб работоспособен: интерфейс открывается, показывает пустое состояние, предлагает подключить аккаунт.
## P0-5. Проверки установщика не должны зависеть от расстановки
Сегодняшняя поломка возникла именно здесь, и это надо закрыть на будущее.
`scripts/verify_multi_provider_router.py` уже переписан ревьюером: имя оркестрирующей роли спрашивается у реестра, пустые цепочки допустимы у ролей без аккаунтов, отказоустойчивость проверяется по механизму, а не по зашитому порядку.
Требуется убедиться, что скрипт проходит **на пустой конфигурации первой установки**. Сейчас он этого случая не видел: у него всегда было 24 профиля. Проверка, падающая на чистой машине, снова даст код 12 — только теперь у нового пользователя.
## P0-6. Аудит вторым проходом
1. **Проверить на копии конфигурации владельца**, что обновление ничего не сбрасывает. Цепочки и порядок аккаунтов обязаны совпасть до и после.
2. **Проверить, что учётные данные целы** после сброса: файлы в `agy_profiles/` на месте, аккаунты по-прежнему подключены.
3. **Установить начисто** в пустой `HERMES_HOME` и пройти путь целиком: установка, открытие интерфейса, подключение аккаунта, назначение роли.
4. **Проверочный скрипт установщика** прогнать и на пустой конфигурации, и на конфигурации владельца.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Учётные данные не удалять и не переносить ни при каких условиях.
- Обновление поверх существующей установки ничего не сбрасывает.
- Версию `0.1.1` не поднимать: сборки различаются коммитом.
- Правило честности без исключений: пустое состояние показывать как пустое, а не как ошибку.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Установка в пустой `HERMES_HOME` даёт конфигурацию без предсозданных профилей; интерфейс показывает внятное пустое состояние.
3. Обновление поверх конфигурации владельца не меняет ни одной цепочки; проверено на копии, вывод приложен.
4. Подключение аккаунта создаёт профиль; заранее заготовленных пустых нет.
5. Кнопка сброса спрашивает подтверждение, перечисляет сохраняемое, делает резервную копию и не трогает учётные данные; проверено.
6. `verify_multi_provider_router.py` проходит и на пустой конфигурации, и на конфигурации владельца; оба вывода приложены.
7. Путь целиком пройден вручную на чистой установке: подключение аккаунта, назначение роли, работа маршрутизации.
8. `ruff check .` чисто; релизный гейт не ухудшен.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `main` сейчас **486 passed**.
## Главное
Сегодняшняя ошибка установки возникла не из-за нового кода, а из-за состояния, накопленного за десяток версий. Чистый старт убирает целый класс таких поломок: новый пользователь получает пустую систему и заполняет её сам, а не разбирается с двумя десятками заготовок, часть из которых помнит переименования полугодовой давности.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -1,21 +1,21 @@
# Hermes Hub: Multi-Provider Account Router Default Profiles Template
# Copy this file to %LOCALAPPDATA%\hermes\config\router_profiles.yaml if not present.
# Clean initial configuration (0 profiles, 13 canonical roles with empty chains).
# Accounts are dynamically created and mapped as you connect them.
router:
enabled: true
default_role: "orchestrator"
default_role: "manager"
max_failover_attempts: 3
cooldown_base_seconds: 300
cooldown_max_seconds: 3600
session_affinity_ttl_seconds: 1800
quota_threshold_percent: 10.0
quota_threshold_action: "notify"
roles:
orchestrator:
role_name: "orchestrator"
preferred_chain:
- "codex-orch"
- "ag-orch-fallback"
- "opengo-3"
manager:
role_name: "manager"
preferred_chain: []
fallback_capabilities:
- "orchestrator"
- "reasoning"
@ -23,281 +23,114 @@ roles:
session_affinity_enabled: true
default_model: "gemini-3.7-flash"
coder-primary:
role_name: "coder-primary"
preferred_chain:
- "codex-worker-1"
- "ag-w1"
- "opengo-3"
developer-1:
role_name: "developer-1"
preferred_chain: []
fallback_capabilities:
- "coding"
max_failover_attempts: 3
session_affinity_enabled: true
default_model: "gemini-3.7-flash"
coder-secondary:
role_name: "coder-secondary"
preferred_chain:
- "codex-worker-2"
- "ag-w2"
- "opengo-2"
developer-2:
role_name: "developer-2"
preferred_chain: []
fallback_capabilities:
- "coding"
- "reviewer"
max_failover_attempts: 3
session_affinity_enabled: true
default_model: "gemini-3.1-pro-high"
reviewer:
role_name: "reviewer"
preferred_chain:
- "codex-worker-2"
- "opengo-2"
- "ag-w2"
code-reviewer:
role_name: "code-reviewer"
preferred_chain: []
fallback_capabilities:
- "reviewer"
- "coding"
max_failover_attempts: 3
session_affinity_enabled: true
default_model: "claude-opus-4-6-thinking"
research:
role_name: "research"
preferred_chain:
- "opengo-1"
- "ag-w3"
- "ag-w4"
researcher:
role_name: "researcher"
preferred_chain: []
fallback_capabilities:
- "research"
- "fast"
- "search"
max_failover_attempts: 3
session_affinity_enabled: true
fast:
role_name: "fast"
preferred_chain:
- "opengo-1"
- "ag-w4"
- "ag-spare-1"
tester:
role_name: "tester"
preferred_chain: []
fallback_capabilities:
- "fast"
- "testing"
max_failover_attempts: 3
session_affinity_enabled: true
tech-writer:
role_name: "tech-writer"
preferred_chain: []
fallback_capabilities:
- "documentation"
- "reasoning"
max_failover_attempts: 3
session_affinity_enabled: true
analyst:
role_name: "analyst"
preferred_chain: []
fallback_capabilities:
- "reasoning"
- "research"
max_failover_attempts: 3
session_affinity_enabled: true
guardian:
role_name: "guardian"
preferred_chain: []
fallback_capabilities:
- "guardian"
- "security"
max_failover_attempts: 0
session_affinity_enabled: true
cost-controller:
role_name: "cost-controller"
preferred_chain: []
fallback_capabilities:
- "cost-controller"
- "budget"
max_failover_attempts: 0
session_affinity_enabled: true
integration-expert:
role_name: "integration-expert"
preferred_chain: []
fallback_capabilities:
- "integration"
- "coding"
max_failover_attempts: 3
session_affinity_enabled: false
session_affinity_enabled: true
profiles:
# Antigravity (10 accounts)
ag-orch-fallback:
profile_id: "ag-orch-fallback"
provider: "antigravity"
account_id: "ag-acc-orch"
enabled: true
capabilities: ["orchestrator", "reasoning", "coding"]
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
max_concurrency: 2
security-expert:
role_name: "security-expert"
preferred_chain: []
fallback_capabilities:
- "security"
- "code-reviewer"
max_failover_attempts: 3
session_affinity_enabled: true
ag-w1:
profile_id: "ag-w1"
provider: "antigravity"
account_id: "ag-acc-w1"
enabled: true
capabilities: ["coding", "coder-primary", "reasoning"]
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
max_concurrency: 2
dependency-agent:
role_name: "dependency-agent"
preferred_chain: []
fallback_capabilities:
- "dependency-agent"
- "preflight"
max_failover_attempts: 3
session_affinity_enabled: true
ag-w2:
profile_id: "ag-w2"
provider: "antigravity"
account_id: "ag-acc-w2"
enabled: true
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
max_concurrency: 2
ag-w3:
profile_id: "ag-w3"
provider: "antigravity"
account_id: "ag-acc-w3"
enabled: true
capabilities: ["research", "reasoning", "search"]
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6"]
max_concurrency: 2
ag-w4:
profile_id: "ag-w4"
provider: "antigravity"
account_id: "ag-acc-w4"
enabled: true
capabilities: ["coding", "reasoning", "fast"]
preferred_models: ["gemini-3.5-flash", "gemini-3.7-flash"]
max_concurrency: 2
ag-spare-1:
profile_id: "ag-spare-1"
provider: "antigravity"
account_id: "ag-acc-sp1"
enabled: true
capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"]
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
max_concurrency: 2
ag-spare-2:
profile_id: "ag-spare-2"
provider: "antigravity"
account_id: "ag-acc-sp2"
enabled: true
capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"]
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
max_concurrency: 2
ag-cold-1:
profile_id: "ag-cold-1"
provider: "antigravity"
account_id: "ag-acc-cold1"
enabled: false
capabilities: ["cold-spare"]
preferred_models: []
max_concurrency: 1
ag-cold-2:
profile_id: "ag-cold-2"
provider: "antigravity"
account_id: "ag-acc-cold2"
enabled: false
capabilities: ["cold-spare"]
preferred_models: []
max_concurrency: 1
ag-cold-3:
profile_id: "ag-cold-3"
provider: "antigravity"
account_id: "ag-acc-cold3"
enabled: false
capabilities: ["cold-spare"]
preferred_models: []
max_concurrency: 1
# OpenAI Codex (3 accounts)
codex-orch:
profile_id: "codex-orch"
provider: "openai-codex"
account_id: "codex-acc-1"
enabled: true
capabilities: ["orchestrator", "coding", "reasoning"]
preferred_models: ["gpt-4o", "o3-mini", "codex"]
max_concurrency: 2
codex-worker-1:
profile_id: "codex-worker-1"
provider: "openai-codex"
account_id: "codex-acc-2"
enabled: true
capabilities: ["coding", "coder-primary", "reasoning"]
preferred_models: ["gpt-4o", "o3-mini", "codex"]
max_concurrency: 2
codex-worker-2:
profile_id: "codex-worker-2"
provider: "openai-codex"
account_id: "codex-acc-3"
enabled: true
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
preferred_models: ["gpt-4o", "o3-mini", "codex"]
max_concurrency: 2
# OpenCode Go (3 accounts)
opengo-1:
profile_id: "opengo-1"
provider: "opencode-go"
account_id: "opengo-acc-1"
enabled: true
capabilities: ["research", "search", "fast", "review"]
preferred_models: ["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"]
max_concurrency: 3
opengo-2:
profile_id: "opengo-2"
provider: "opencode-go"
account_id: "opengo-acc-2"
enabled: true
capabilities: ["reviewer", "review", "coding", "reasoning"]
preferred_models: ["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"]
max_concurrency: 3
opengo-3:
profile_id: "opengo-3"
provider: "opencode-go"
account_id: "opengo-acc-3"
enabled: true
capabilities: ["coder-fallback", "orchestrator", "coding", "reasoning"]
preferred_models: ["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"]
max_concurrency: 3
# Claude / Anthropic (3 accounts)
claude-orch:
profile_id: "claude-orch"
provider: "claude"
account_id: "claude-acc-1"
enabled: true
capabilities: ["orchestrator", "coding", "reasoning"]
preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"]
max_concurrency: 2
claude-worker-1:
profile_id: "claude-worker-1"
provider: "claude"
account_id: "claude-acc-2"
enabled: true
capabilities: ["coding", "coder-primary", "reasoning"]
preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"]
max_concurrency: 2
claude-worker-2:
profile_id: "claude-worker-2"
provider: "claude"
account_id: "claude-acc-3"
enabled: true
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku"]
max_concurrency: 2
# Grok / xAI (3 accounts)
grok-orch:
profile_id: "grok-orch"
provider: "grok"
account_id: "grok-acc-1"
enabled: true
capabilities: ["orchestrator", "coding", "reasoning"]
preferred_models: ["grok-3", "grok-3-mini", "grok-4.5"]
max_concurrency: 2
grok-worker-1:
profile_id: "grok-worker-1"
provider: "grok"
account_id: "grok-acc-2"
enabled: true
capabilities: ["coding", "coder-primary", "reasoning"]
preferred_models: ["grok-3", "grok-3-mini", "grok-4.5"]
max_concurrency: 2
grok-worker-2:
profile_id: "grok-worker-2"
provider: "grok"
account_id: "grok-acc-3"
enabled: true
capabilities: ["research", "reasoning", "fast"]
preferred_models: ["grok-3", "grok-3-mini"]
max_concurrency: 2
# Optional: User Model Pricing Table (USD per 1M tokens)
# Telemetry will compute call cost in USD only if a model price is defined below.
pricing:
gemini-2.5-pro:
input_cost_per_m: 1.25
output_cost_per_m: 5.00
gemini-2.5-flash:
input_cost_per_m: 0.15
output_cost_per_m: 0.60
gpt-4o:
input_cost_per_m: 2.50
output_cost_per_m: 10.00
claude-3-7-sonnet:
input_cost_per_m: 3.00
output_cost_per_m: 15.00
profiles: {}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
@ -137,7 +137,7 @@ namespace HermesHubSetup
{
ProcessStartInfo checkPsi = new ProcessStartInfo();
checkPsi.FileName = pythonExe;
checkPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil, fastapi, uvicorn; print('DEPS_OK')\"";
checkPsi.Arguments = "-c \"import yaml, psutil, fastapi, uvicorn; print('DEPS_OK')\"";
checkPsi.UseShellExecute = false;
checkPsi.RedirectStandardOutput = true;
checkPsi.RedirectStandardError = true;
@ -159,7 +159,7 @@ namespace HermesHubSetup
if (needsInstall)
{
if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (customtkinter, Pillow, PyYAML, psutil, FastAPI, uvicorn)...", 40);
if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (PyYAML, psutil, FastAPI, uvicorn)...", 40);
// 1. Ensure pip is installed/bootstrapped if needed
try
@ -185,7 +185,7 @@ namespace HermesHubSetup
{
ProcessStartInfo pipPsi = new ProcessStartInfo();
pipPsi.FileName = pythonExe;
pipPsi.Arguments = "-m pip install --no-warn-script-location customtkinter pillow pyyaml psutil fastapi uvicorn";
pipPsi.Arguments = "-m pip install --no-warn-script-location pyyaml psutil fastapi uvicorn";
pipPsi.UseShellExecute = false;
pipPsi.RedirectStandardOutput = true;
pipPsi.RedirectStandardError = true;
@ -209,7 +209,7 @@ namespace HermesHubSetup
{
ProcessStartInfo uvPsi = new ProcessStartInfo();
uvPsi.FileName = "uv";
uvPsi.Arguments = string.Format("pip install --python \"{0}\" customtkinter pillow pyyaml psutil fastapi uvicorn", pythonExe);
uvPsi.Arguments = string.Format("pip install --python \"{0}\" pyyaml psutil fastapi uvicorn", pythonExe);
uvPsi.UseShellExecute = false;
uvPsi.RedirectStandardOutput = true;
uvPsi.RedirectStandardError = true;
@ -237,7 +237,7 @@ namespace HermesHubSetup
{
ProcessStartInfo recheckPsi = new ProcessStartInfo();
recheckPsi.FileName = pythonExe;
recheckPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil, fastapi, uvicorn; print('DEPS_VERIFIED')\"";
recheckPsi.Arguments = "-c \"import yaml, psutil, fastapi, uvicorn; print('DEPS_VERIFIED')\"";
recheckPsi.UseShellExecute = false;
recheckPsi.RedirectStandardOutput = true;
recheckPsi.RedirectStandardError = true;
@ -309,7 +309,7 @@ namespace HermesHubSetup
}
// 2. Install UI & System Dependencies into Hermes Python Environment
if (progressCallback != null) progressCallback("Checking Python dependencies (customtkinter, Pillow, FastAPI, uvicorn)...", 35);
if (progressCallback != null) progressCallback("Checking Python dependencies (FastAPI, uvicorn, PyYAML, psutil)...", 35);
if (!EnsurePythonDependencies(HermesPython, progressCallback))
{
return 13; // Dependency install failed
@ -376,7 +376,7 @@ namespace HermesHubSetup
// 8. Post-install Verification & Import Smoke Test
if (progressCallback != null) progressCallback("Running post-install import validation...", 90);
string pluginSrcDir = Path.Combine(HermesHome, @"plugins\antigravity-provider\src");
string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); import customtkinter; from PIL import Image; import antigravity_provider.router.hermes_hub_app; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); import fastapi, uvicorn; import antigravity_provider.router.web.server; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
ProcessStartInfo smokePsi = new ProcessStartInfo();
smokePsi.FileName = HermesPython;
smokePsi.Arguments = smokeCmd;
@ -552,36 +552,25 @@ namespace HermesHubSetup
string targetDesktopExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
if (!File.Exists(targetDesktopExe)) targetDesktopExe = Path.Combine(HermesHome, "HermesHub.exe");
// Clean up legacy shortcut names upon upgrade
string[] oldShortcuts = new string[]
{
"Hermes Hub (Web).lnk",
"Hermes Hub (Desktop).lnk",
"Hermes Hub Web.lnk"
};
foreach (string sc in oldShortcuts)
{
string p = Path.Combine(startMenu, sc);
if (File.Exists(p)) try { File.Delete(p); } catch { }
}
Type shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType != null)
{
dynamic shell = Activator.CreateInstance(shellType);
// 1. Web Application Window Shortcut (Primary Web App mode)
if (File.Exists(targetWebExe))
{
string webShortcutPath = Path.Combine(startMenu, "Hermes Hub (Web).lnk");
dynamic webShortcut = shell.CreateShortcut(webShortcutPath);
webShortcut.TargetPath = targetWebExe;
webShortcut.WorkingDirectory = TargetInstallDir;
webShortcut.Description = "Hermes Hub — Web Application Window";
webShortcut.IconLocation = targetWebExe + ",0";
webShortcut.Save();
}
// 2. Native Desktop Shortcut (CustomTkinter GUI)
if (File.Exists(targetDesktopExe))
{
string desktopShortcutPath = Path.Combine(startMenu, "Hermes Hub (Desktop).lnk");
dynamic desktopShortcut = shell.CreateShortcut(desktopShortcutPath);
desktopShortcut.TargetPath = targetDesktopExe;
desktopShortcut.WorkingDirectory = TargetInstallDir;
desktopShortcut.Description = "Hermes Hub — Native Desktop App";
desktopShortcut.IconLocation = targetDesktopExe + ",0";
desktopShortcut.Save();
}
// 3. Standard "Hermes Hub.lnk" shortcut
// Single standard "Hermes Hub.lnk" shortcut for Web Interface
string standardTarget = File.Exists(targetWebExe) ? targetWebExe : targetDesktopExe;
if (File.Exists(standardTarget))
{

View file

@ -33,8 +33,8 @@ def check_hermes_agent_installed() -> bool:
def verify_dependencies(venv_python: Path) -> bool:
"""Verify that required UI packages can be imported without error."""
code = "import customtkinter; from PIL import Image; import yaml; import psutil; print('OK')"
"""Verify that required packages can be imported without error."""
code = "import yaml; import psutil; import fastapi; import uvicorn; print('OK')"
try:
res = subprocess.run(
[str(venv_python), "-c", code],
@ -48,8 +48,8 @@ def verify_dependencies(venv_python: Path) -> bool:
def install_dependencies(venv_python: Path) -> bool:
"""Install required UI packages into Hermes venv."""
packages = ["customtkinter>=6.0.0", "pillow>=10.0.0", "psutil>=5.9.0", "pyyaml>=6.0.1", "requests>=2.31.0"]
"""Install required packages into Hermes venv."""
packages = ["fastapi>=0.110.0", "uvicorn>=0.28.0", "psutil>=5.9.0", "pyyaml>=6.0.1", "requests>=2.31.0"]
try:
res = subprocess.run(
[str(venv_python), "-m", "pip", "install", "--upgrade"] + packages,
@ -95,13 +95,13 @@ def run_installation(silent: bool = False):
print(f" ✓ Hermes Agent найден ({venv_python})")
# 2. Dependency verification and install
print("\n[2/5] Проверка и установка зависимостей UI (customtkinter, Pillow)...")
print("\n[2/5] Проверка и установка зависимостей (FastAPI, uvicorn, psutil, pyyaml)...")
if not verify_dependencies(venv_python):
print(" Установка недостающих пакетов в окружение Hermes...")
ok = install_dependencies(venv_python)
if not ok or not verify_dependencies(venv_python):
print("\n" + "!" * 60)
print(" [ОШИБКА УСТАНОВКИ] Не удалось установить зависимости GUI (customtkinter / Pillow)!")
print(" [ОШИБКА УСТАНОВКИ] Не удалось установить зависимости (FastAPI / uvicorn / psutil / pyyaml)!")
print(" Пожалуйста, проверьте подключение к сети и права доступа к venv.")
print("!" * 60 + "\n")
if not silent:

View file

@ -33,11 +33,46 @@ printf '%s' "$COMMIT" > "$STAGE/BUILD_COMMIT"
#
# Сборка идёт на Windows, где рабочая копия хранится с CRLF (core.autocrlf).
# В git объекты лежат с LF, но сборщик копирует из РАБОЧЕЙ КОПИИ, поэтому
# .gitattributes её не спасает. Установщик с падает на первой же строке:
# "$' ': command not found", а строка-маркер с не совпадает, и пролог не
# находит границу вложенных данных. Именно так сломалась сборка 44808bd.
# .gitattributes её не спасает. Установщик с возвратом каретки падает на
# первой же строке, а строка-маркер перестаёт совпадать, и пролог не находит
# границу вложенных данных. Именно так сломалась сборка 44808bd.
#
# Делается на Python, а не sed: sed -i из Git Bash на Windows молча не
# убирает возврат каретки — после его работы в install-linux.sh оставалось
# 207 таких строк, и в поставку снова уходил битый установщик.
# python3 на Windows — заглушка Microsoft Store: она печатает "Python" и
# ничего не выполняет, из-за чего нормализация тихо не срабатывала.
PYBIN=""
for cand in python3 python py; do
if command -v "$cand" >/dev/null 2>&1 && "$cand" -c "import sys" >/dev/null 2>&1; then
PYBIN="$cand"; break
fi
done
if [ -z "$PYBIN" ]; then
echo "ОШИБКА: не найден рабочий Python для нормализации переводов строк." >&2
echo "Собирать установщик без неё нельзя: он сломается на Linux." >&2
exit 1
fi
echo "Нормализация переводов строк..."
find "$STAGE" -type f \( -name '*.sh' -o -name '*.py' -o -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) -print0 | xargs -0 -r sed -i 's/ $//'
"$PYBIN" - "$STAGE" <<'NORMALIZE'
import sys
from pathlib import Path
CRLF = bytes([13, 10])
LF = bytes([10])
root = Path(sys.argv[1])
suffixes = {".sh", ".py", ".yaml", ".yml", ".json"}
fixed = 0
for path in root.rglob("*"):
if not path.is_file() or path.suffix not in suffixes:
continue
raw = path.read_bytes()
clean = raw.replace(CRLF, LF)
if clean != raw:
path.write_bytes(clean)
fixed += 1
print(" переводы строк исправлены в %d файлах" % fixed)
NORMALIZE
find "$STAGE" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
find "$STAGE" -name '*.pyc' -delete 2>/dev/null || true

View file

@ -37,10 +37,10 @@ dependencies = [
"pydantic>=2.6.0",
"requests>=2.31.0",
"httpx>=0.27.0",
"customtkinter>=6.0.0",
"pillow>=12.3.0",
"psutil>=5.9.0",
"gguf>=0.19.0",
"fastapi>=0.110.0",
"uvicorn>=0.28.0",
]
[project.optional-dependencies]
@ -51,10 +51,6 @@ dev = [
"anyio>=4.0.0",
"ruff>=0.3.0",
]
web = [
"fastapi>=0.110.0",
"uvicorn>=0.28.0",
]
[project.scripts]
hermes-hub = "antigravity_provider.router.cli_commands:main"

View file

@ -6,6 +6,7 @@ import os
import sys
import time
from pathlib import Path
from contextlib import ExitStack
from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parent.parent
@ -17,7 +18,13 @@ for p in [
if p.is_dir() and str(p) not in sys.path:
sys.path.insert(0, str(p))
from antigravity_provider.router.router_config import get_default_router_config, load_router_config
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
get_default_router_config,
load_router_config,
)
from antigravity_provider.router.health_tracker import (
HEALTHY,
QUOTA_EXHAUSTED,
@ -30,6 +37,9 @@ from antigravity_provider.router.router_engine import RouterEngine, get_router_e
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter, get_profile_env_dir
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter
from antigravity_provider.router.adapters.claude_adapter import ClaudeAdapter
from antigravity_provider.router.adapters.grok_adapter import GrokAdapter
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
def run_checks() -> int:
@ -42,37 +52,41 @@ def run_checks() -> int:
# 1. Config inventory
print("1. Checking profile inventory and provider counts...")
config = get_default_router_config()
# Проверка структурная, а не пересчёт. Раньше здесь стояло «ровно 16
# профилей» и дословные цепочки от 20 августа. Миграция законно довела
# конфигурацию до 22 профилей, добавив claude и grok, — и установка стала
# падать с кодом 12 на любой машине, где миграция отработала. Смысл этой
# проверки в том, работоспособна ли маршрутизация, а не совпадает ли
# конфигурация с зафиксированной когда-то.
counts = {}
for prof in config.profiles.values():
counts[prof.provider] = counts.get(prof.provider, 0) + 1
assert config.profiles, "В конфигурации нет ни одного профиля"
for required in ("openai-codex", "antigravity", "opencode-go"):
assert counts.get(required), f"Нет ни одного профиля провайдера {required}"
summary = ", ".join(f"{prov}: {n}" for prov, n in sorted(counts.items()))
print(f" [PASS] Профилей: {len(config.profiles)} ({summary})")
config = load_router_config()
if config.profiles:
counts = {}
for prof in config.profiles.values():
counts[prof.provider] = counts.get(prof.provider, 0) + 1
summary = ", ".join(f"{prov}: {n}" for prov, n in sorted(counts.items()))
print(f" [PASS] Профилей: {len(config.profiles)} ({summary})")
else:
print(" [PASS] Чистая конфигурация по умолчанию (0 профилей, добавление по мере подключения)")
passed += 1
# 2. Role Fallback Chains
print("2. Checking role fallback policies...")
assert "orchestrator" in config.roles, "Роль orchestrator отсутствует"
# Цепочки настраиваются владельцем и меняются — дословно их сверять нельзя.
# Проверяем то, что действительно ломает маршрутизацию: цепочка непуста и
# каждый профиль в ней существует.
assert config.roles, "В конфигурации нет ни одной роли"
try:
from antigravity_provider.router.role_registry import RoleRegistry
orchestrating_role = RoleRegistry.resolve_canonical_role("orchestrator")
except Exception:
orchestrating_role = "manager"
assert orchestrating_role in config.roles, (
f"Оркестрирующая роль {orchestrating_role!r} отсутствует; есть: {sorted(config.roles)}"
)
empty_chains = []
for role_name, policy in config.roles.items():
chain = policy.preferred_chain or []
assert chain, f"У роли {role_name} пустая цепочка отказоустойчивости"
if not chain:
empty_chains.append(role_name)
for pid in chain:
assert pid in config.profiles, (
f"Роль {role_name} ссылается на несуществующий профиль {pid}"
)
print(f" [PASS] Цепочки {len(config.roles)} ролей ссылаются только на существующие профили")
if empty_chains:
print(f" [INFO] Роли с пустыми цепочками (чистый старт / не назначены): {', '.join(sorted(empty_chains))}")
print(f" [PASS] Все {len(config.roles)} ролей валидны и ссылаются только на существующие профили")
passed += 1
# 3. Model family extraction
@ -148,29 +162,83 @@ def run_checks() -> int:
# 9. Full failover execution loop
print("9. Checking full failover execution loop...")
engine = RouterEngine(config=config)
engine.health.clear_cooldown()
full_chain = list(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)).preferred_chain)
max_attempts = getattr(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)), "max_failover_attempts", 0) or len(full_chain)
chain = full_chain[:max_attempts]
mock_codex = {"id": "c1", "choices": [{"message": {"role": "assistant", "content": "from-codex"}}]}
mock_ag = {"id": "a1", "choices": [{"message": {"role": "assistant", "content": "from-antigravity"}}]}
mock_opengo = {"id": "o1", "choices": [{"message": {"role": "assistant", "content": "from-opencode"}}]}
adapter_by_provider = {
"openai-codex": CodexAdapter,
"antigravity": AntigravityAdapter,
"opencode-go": OpenCodeGoAdapter,
"claude": ClaudeAdapter,
"grok": GrokAdapter,
"local": LocalLLMAdapter,
}
# Simulate codex failure -> route to Antigravity fallback
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Insufficient quota")):
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
res = engine.route_request({"messages": [{"role": "user", "content": "test"}]}, role="orchestrator", session_id="s1")
assert res["choices"][0]["message"]["content"] == "from-antigravity"
assert res["router_metadata"]["profile_id"] == "ag-orch-fallback"
if len(chain) >= 2 and all(pid in config.profiles for pid in chain):
test_engine = RouterEngine(config=config)
test_engine.health.clear_cooldown()
test_role = orchestrating_role
test_chain = chain
test_profiles = config.profiles
else:
synth_config = RouterConfig(
enabled=True,
default_role="manager",
roles={
"manager": RolePolicy(
role_name="manager",
preferred_chain=["synth-codex", "synth-ag"],
max_failover_attempts=2,
)
},
profiles={
"synth-codex": RouterProfileConfig(
profile_id="synth-codex",
provider="openai-codex",
capabilities=["coding", "orchestrator"],
),
"synth-ag": RouterProfileConfig(
profile_id="synth-ag",
provider="antigravity",
capabilities=["coding", "orchestrator"],
),
},
)
test_engine = RouterEngine(config=synth_config)
test_engine.health.clear_cooldown()
test_role = "manager"
test_chain = ["synth-codex", "synth-ag"]
test_profiles = synth_config.profiles
# Simulate both codex and ag failure -> route to OpenCode Go
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Insufficient quota")):
with patch.object(AntigravityAdapter, "invoke", side_effect=RuntimeError("Individual quota reached")):
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo):
res2 = engine.route_request({"messages": [{"role": "user", "content": "test2"}]}, role="orchestrator", session_id="s2")
assert res2["choices"][0]["message"]["content"] == "from-opencode"
assert res2["router_metadata"]["profile_id"] == "opengo-3"
last_pid = test_chain[-1]
last_provider = test_profiles[last_pid].provider
print(" [PASS] 3-tier role failover chain (Codex -> Antigravity -> OpenCode Go) verified")
expected = {"id": "ok", "choices": [{"message": {"role": "assistant", "content": "from-last-in-chain"}}]}
failing = {cls for pid in test_chain[:-1]
if (cls := adapter_by_provider.get(test_profiles[pid].provider)) is not None}
winner = adapter_by_provider.get(last_provider)
if winner is None or winner in failing:
print(f" [SKIP] Последний профиль цепочки ({last_pid}, {last_provider}) "
"не покрыт заглушками адаптеров")
else:
with ExitStack() as stack:
for cls in failing:
stack.enter_context(patch.object(cls, "invoke", side_effect=RuntimeError("Insufficient quota")))
stack.enter_context(patch.object(winner, "invoke", return_value=expected))
res = test_engine.route_request(
{"messages": [{"role": "user", "content": "test"}]},
role=test_role,
session_id="verify-failover",
)
assert res["choices"][0]["message"]["content"] == "from-last-in-chain", (
f"Отказоустойчивость не дошла до последнего профиля цепочки: {res}"
)
assert res["router_metadata"]["profile_id"] == last_pid, (
f"Ожидался профиль {last_pid}, получен {res['router_metadata']['profile_id']}"
)
print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(test_chain)}")
passed += 1
# 10. Passthrough & Graceful fallback

View file

@ -1,9 +1,12 @@
import csv
import io
import time
import json
import os
import logging
import threading
from typing import Any, Dict, Tuple, Optional, Callable
from datetime import datetime, timezone
from typing import Any, Dict, Tuple, Optional, Callable, List
from antigravity_provider.router.router_config import load_router_config, save_router_config
from antigravity_provider.router.profile_manager import ProfileAuthManager
@ -35,6 +38,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
AutoAssigner.ensure_profile_definition(provider, profile_id)
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
@ -277,6 +284,42 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
return False, "Не удалось сохранить файл конфигурации"
def do_save_request_options(profile_id: str, request_options: Any) -> Tuple[bool, str]:
if not profile_id or not str(profile_id).strip():
return False, "Не указан идентификатор профиля"
if isinstance(request_options, str):
try:
request_options = json.loads(request_options)
except Exception as exc:
return False, f"Некорректный JSON параметров запроса: {exc}"
if not isinstance(request_options, dict):
return False, "Параметры запроса должны быть объектом (словарём)"
cfg = load_router_config()
if profile_id not in cfg.profiles:
return False, f"Профиль '{profile_id}' не найден в конфигурации"
pcfg = cfg.profiles[profile_id]
pcfg.request_options = request_options
cfg.profiles[profile_id] = pcfg
if save_router_config(cfg):
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
EventLogService.get().log(
"account",
f"Параметры запроса для профиля {profile_id} ({pcfg.provider}) сохранены.",
level="info",
)
return True, f"Параметры запроса для профиля {profile_id} успешно сохранены"
return False, "Не удалось сохранить файл конфигурации"
# Подключённый аккаунт обязан появиться в списке сразу.
#
# Учётные данные сохраняются на диск, но состояние профилей берётся из кэша
@ -298,6 +341,195 @@ def _rescan_after_auth() -> None:
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
def generate_quotas_export(format: str = "json") -> Any:
"""Generate comprehensive limits and quotas export across all providers and profiles."""
from antigravity_provider.router.quota_collector import AccountQuotaService
config = load_router_config()
quota_service = AccountQuotaService.get()
profiles_data: List[Dict[str, Any]] = []
flat_rows: List[Dict[str, Any]] = []
now_iso = datetime.now(timezone.utc).isoformat()
# Sort profiles by provider then profile_id
sorted_profiles = sorted(config.profiles.values(), key=lambda p: (p.provider, p.profile_id))
for pcfg in sorted_profiles:
prov = pcfg.provider
pid = pcfg.profile_id
status = ProfileAuthManager.get_profile_status(prov, pid)
is_auth = bool(status.get("authenticated"))
identity = quota_service.get_identity(prov, pid)
quota_snap = quota_service.get_snapshot(prov, pid)
plan_name = identity.plan.display_name if (identity and identity.plan) else "UNKNOWN"
ident_str = identity.primary_identifier() if identity else pid
buckets_list: List[Dict[str, Any]] = []
source = quota_snap.source if quota_snap else "unconfigured"
fetched_at = quota_snap.fetched_at.isoformat() if (quota_snap and quota_snap.fetched_at) else now_iso
unavail = quota_snap.unavailable_reason if quota_snap else None
if quota_snap and quota_snap.buckets:
for b in quota_snap.buckets:
b_dict = {
"id": b.id,
"name": b.display_name,
"model_family": b.model_family,
"period": b.period,
"used_percent": b.used_percent,
"remaining_percent": b.remaining_percent,
"used_absolute": b.used_absolute,
"remaining_absolute": b.remaining_absolute,
"limit_absolute": b.limit_absolute,
"reset_at": b.reset_at.isoformat() if b.reset_at else None,
"reset_in_seconds": b.reset_in_seconds,
"reset_formatted": b.formatted_reset(),
"status": b.status,
"formatted_remaining": b.formatted_remaining(),
}
buckets_list.append(b_dict)
flat_rows.append({
"provider": prov,
"profile_id": pid,
"identity": ident_str,
"plan": plan_name,
"auth_status": "AUTHENTICATED" if is_auth else "UNCONFIGURED",
"bucket_id": b.id,
"bucket_name": b.display_name,
"model_family": b.model_family or "",
"period": b.period or "",
"remaining_percent": f"{b.remaining_percent:.1f}%" if b.remaining_percent is not None else "",
"used_percent": f"{b.used_percent:.1f}%" if b.used_percent is not None else "",
"remaining_absolute": b.remaining_absolute if b.remaining_absolute is not None else "",
"limit_absolute": b.limit_absolute if b.limit_absolute is not None else "",
"status": b.status,
"reset_at": b.reset_at.isoformat() if b.reset_at else "",
"reset_formatted": b.formatted_reset() or "",
"formatted_remaining": b.formatted_remaining(),
"source": source,
"fetched_at": fetched_at,
})
else:
flat_rows.append({
"provider": prov,
"profile_id": pid,
"identity": ident_str,
"plan": plan_name,
"auth_status": "AUTHENTICATED" if is_auth else "UNCONFIGURED",
"bucket_id": "",
"bucket_name": "",
"model_family": "",
"period": "",
"remaining_percent": "",
"used_percent": "",
"remaining_absolute": "",
"limit_absolute": "",
"status": "unconfigured" if not is_auth else "unknown",
"reset_at": "",
"reset_formatted": "",
"formatted_remaining": unavail or ("Аккаунт не подключён" if not is_auth else "Н"),
"source": source,
"fetched_at": fetched_at,
})
profiles_data.append({
"provider": prov,
"profile_id": pid,
"display_name": getattr(pcfg, "display_name", None) or (identity.display_name if identity else None) or pid,
"identity": ident_str,
"plan": plan_name,
"authenticated": is_auth,
"auth_status": "AUTHENTICATED" if is_auth else "UNCONFIGURED",
"source": source,
"fetched_at": fetched_at,
"unavailable_reason": unavail,
"buckets": buckets_list,
})
if format.lower().strip() == "csv":
output = io.StringIO()
fieldnames = [
"provider", "profile_id", "identity", "plan", "auth_status",
"bucket_id", "bucket_name", "model_family", "period",
"remaining_percent", "used_percent", "remaining_absolute", "limit_absolute",
"status", "reset_at", "reset_formatted", "formatted_remaining",
"source", "fetched_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in flat_rows:
writer.writerow(row)
return output.getvalue()
return {
"exported_at": now_iso,
"total_profiles": len(profiles_data),
"profiles": profiles_data,
"rows": flat_rows,
}
def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]:
"""Reset router configuration to clean default state (0 profiles, 13 canonical roles with empty chains).
Guaranteed to create a timestamped backup of router_profiles.yaml first.
Strictly preserves all credentials in ~/.hermes/agy_profiles, codex_profiles,
opengo_profiles, claude_profiles, grok_profiles, and hub_settings.json.
"""
import shutil
from pathlib import Path
from antigravity_provider.router.router_config import (
get_default_router_config,
save_router_config,
)
env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip()
if env_config:
config_path = Path(env_config).expanduser()
else:
config_path = paths.get_router_profiles_path()
backup_name = None
if config_path.exists():
try:
backup_path = config_path.with_name(f"{config_path.name}.bak_{int(time.time())}")
shutil.copy2(config_path, backup_path)
backup_name = backup_path.name
except Exception as exc:
logger.warning("Не удалось создать резервную копию перед сбросом конфигурации: %s", exc)
clean_cfg = get_default_router_config()
saved = save_router_config(clean_cfg, config_path)
if not saved:
return {
"ok": False,
"message": "Не удалось записать чистую конфигурацию в файл",
}
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
EventLogService.get().log(
"routing",
f"Конфигурация маршрутизатора сброшена в исходное состояние (резервная копия: {backup_name or 'нет'}). Учётные данные и подключенные аккаунты сохранены.",
level="warning",
actor=actor,
action="reset_router_config",
outcome="success",
)
return {
"ok": True,
"message": "Конфигурация маршрутизатора сброшена. Учётные данные и подключенные аккаунты сохранены.",
"backup_file": backup_name,
}
class ActionExecutor:
"""Shared execution layer for Desktop and Web actions."""
@ -321,6 +553,28 @@ class ActionExecutor:
prov = _pcfg.provider
except Exception:
pass
if not prov:
for prefix, p_name in [
("ag-", "antigravity"),
("codex-", "openai-codex"),
("opengo-", "opencode-go"),
("claude-", "claude"),
("grok-", "grok"),
("local-", "local"),
("openrouter-", "openrouter"),
("nvidia-", "nvidia"),
("ollama-", "ollama"),
("vllm-", "vllm"),
]:
if pid.startswith(prefix):
prov = p_name
break
if not prov:
from antigravity_provider.router.profile_manager import get_profile_auth_path
for candidate_prov in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm"):
if get_profile_auth_path(candidate_prov, pid).is_file():
prov = candidate_prov
break
if action in {
'create_agent',
@ -396,25 +650,26 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
# Подключение аккаунта: для локального сервера это не навигация, а
# Подключение аккаунта: для локального сервера/Ollama это не навигация, а
# настоящее сохранение профиля с адресом.
if action == 'add_account':
target_role = data.get('target_role', 'coder-primary')
base_url = data.get('base_url', '')
token = data.get('token', '')
if prov in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm') and base_url:
slot = AutoAssigner.find_free_slot(prov) or 'local-1'
slot = data.get('profile_id') or AutoAssigner.find_free_slot(prov) or f'{prov}-1'
AutoAssigner.ensure_profile_definition(prov, slot)
auth_data = {
"provider": "local",
"provider": prov,
"profile_id": slot,
"base_url": base_url,
"api_key": token if token else None,
"created_at": time.time(),
}
ProfileAuthManager.save_profile_auth("local", slot, auth_data)
ProfileAuthManager.save_profile_auth(prov, slot, auth_data)
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
return {'ok': True, 'message': f'Локальный сервер {slot} успешно подключен'}
_rescan_after_auth()
return {'ok': True, 'message': f'Сервер {prov} ({slot}) успешно подключен'}
return {'ok': True, 'message': 'Навигация'}
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
@ -666,6 +921,12 @@ class ActionExecutor:
ok, msg = do_save_settings(data)
return {'ok': ok, 'message': msg}
elif action in ['save_request_options', 'set_request_options']:
options = data.get('request_options', {})
target_pid = pid or data.get('profile_id', '')
ok, msg = do_save_request_options(target_pid, options)
return {'ok': ok, 'message': msg}
elif action == 'discover_local_models':
# Поиск уже запущенных локальных серверов на машине.
#
@ -735,5 +996,25 @@ class ActionExecutor:
msg = f"Проверка готовности: {report.passed_count} успешно, {report.failed_count} ошибок, {report.warn_count} предупреждений"
return {'ok': report.success, 'message': msg, 'data': report.to_dict()}
elif action == 'export_quotas':
fmt = (data.get('format') or 'json').strip().lower()
res = generate_quotas_export(format=fmt)
if fmt == 'csv':
return {
'ok': True,
'message': 'Экспорт лимитов успешно сформирован (CSV)',
'data': {'format': 'csv', 'content': res, 'filename': 'hermes_quotas_export.csv'}
}
else:
return {
'ok': True,
'message': 'Экспорт лимитов успешно сформирован (JSON)',
'data': {'format': 'json', 'report': res, 'filename': 'hermes_quotas_export.json'}
}
elif action in ['reset_router_config', 'reset_to_empty_config']:
res = do_reset_router_config(actor=actor)
return {'ok': res.get('ok', False), 'message': res.get('message', ''), 'data': res}
else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}

View file

@ -9,7 +9,10 @@ from .codex_adapter import CodexAdapter
from .deepseek_adapter import DeepSeekResponsesAdapter
from .grok_adapter import GrokAdapter
from .local_adapter import LocalLLMAdapter
from .nvidia_adapter import NvidiaAdapter
from .ollama_adapter import OllamaAdapter
from .opencode_adapter import OpenCodeGoAdapter
from .openrouter_adapter import OpenRouterAdapter
_ADAPTERS: dict[str, BaseProviderAdapter] = {
"antigravity": AntigravityAdapter(),
@ -28,8 +31,11 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = {
"local": LocalLLMAdapter(),
"local-llm": LocalLLMAdapter(),
"llama.cpp": LocalLLMAdapter(),
"ollama": LocalLLMAdapter(),
"ollama": OllamaAdapter(),
"vllm": LocalLLMAdapter(),
"openrouter": OpenRouterAdapter(),
"nvidia": NvidiaAdapter(),
"nvidia-nim": NvidiaAdapter(),
}

View file

@ -62,6 +62,20 @@ class ClaudeAdapter(BaseProviderAdapter):
return None
def _build_headers(self, token: str) -> Dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-router/1.0",
}
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
return headers
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
token = self._resolve_token(profile)
if not token:
@ -85,18 +99,7 @@ class ClaudeAdapter(BaseProviderAdapter):
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
headers = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-router/1.0",
}
# OAuth vs API Key headers
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
headers = self._build_headers(token)
body_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body_bytes, headers=headers, method="POST")
@ -117,9 +120,51 @@ class ClaudeAdapter(BaseProviderAdapter):
def health_check(self, profile: RouterProfileConfig) -> bool:
token = self._resolve_token(profile)
return token is not None
if not token:
return False
base_url = (
profile.custom_base_url
or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1")
).rstrip("/")
url = f"{base_url}/models"
headers = self._build_headers(token)
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status in (200, 204)
except Exception:
return False
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
token = self._resolve_token(profile)
if not token:
return list(profile.preferred_models or DEFAULT_CLAUDE_MODELS)
base_url = (
profile.custom_base_url
or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1")
).rstrip("/")
url = f"{base_url}/models"
headers = self._build_headers(token)
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list) and items:
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Claude /models discovery failed for %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or DEFAULT_CLAUDE_MODELS)
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:

View file

@ -164,8 +164,14 @@ class LocalLLMAdapter(BaseProviderAdapter):
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": request.get("temperature", 0.7),
}
if "temperature" in request:
payload["temperature"] = request["temperature"]
elif isinstance(profile.request_options, dict) and "temperature" in profile.request_options:
payload["temperature"] = profile.request_options["temperature"]
else:
payload["temperature"] = request.get("temperature", 0.7)
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
if "tool_choice" in request:
@ -179,6 +185,42 @@ class LocalLLMAdapter(BaseProviderAdapter):
if "stop" in request:
payload["stop"] = request["stop"]
# Mix in request_options from profile (generic, supports any arbitrary keys & nested structures)
req_options = profile.request_options if isinstance(profile.request_options, dict) else {}
for opt_key, opt_val in req_options.items():
if opt_key == "temperature":
if "temperature" in request and request["temperature"] != opt_val:
logger.warning(
"Profile %s request_option '%s' (%r) ignored: request specified explicit value (%r)",
profile.profile_id,
opt_key,
opt_val,
request["temperature"],
)
continue
if opt_key in request:
req_val = request[opt_key]
if req_val != opt_val:
logger.warning(
"Profile %s request_option '%s' (%r) ignored: request specified explicit value (%r)",
profile.profile_id,
opt_key,
opt_val,
req_val,
)
elif opt_key in payload:
if payload[opt_key] != opt_val:
logger.warning(
"Profile %s request_option '%s' (%r) ignored: payload contains (%r)",
profile.profile_id,
opt_key,
opt_val,
payload[opt_key],
)
else:
payload[opt_key] = opt_val
headers: Dict[str, str] = {
"Content-Type": "application/json",
"User-Agent": "hermes-router/1.0",
@ -328,6 +370,14 @@ class LocalLLMAdapter(BaseProviderAdapter):
message=err_msg,
)
# 400 / Bad request / Invalid parameter / Unknown parameter
if any(k in err_lower for k in ("400", "invalid_request", "bad request", "unknown parameter", "invalid parameter", "unknown field", "unrecognized field")):
return ErrorClassification(
category=ErrorCategory.INVALID_REQUEST,
message=err_msg,
retry_delay_seconds=300,
)
# Quota exhausted
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification(

View file

@ -0,0 +1,233 @@
"""NVIDIA NIM (integrate.api.nvidia.com) OpenAI-compatible provider adapter."""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message
logger = logging.getLogger("hermes.router.adapter.nvidia")
DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
class NvidiaAdapter(BaseProviderAdapter):
"""Adapter for NVIDIA NIM's OpenAI-compatible chat completions API."""
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or default."""
url = (
profile.custom_base_url
or profile.auth_config.get("base_url")
or os.environ.get("NVIDIA_BASE_URL")
or DEFAULT_NVIDIA_BASE_URL
)
url_str = str(url).strip().rstrip("/")
if not url_str.startswith(("http://", "https://")):
url_str = f"https://{url_str}"
return url_str
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
"""Resolve API key from profile auth_config or environment."""
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
if key:
return str(key).strip()
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
val = os.environ.get(candidate, "").strip()
if val:
return val
return None
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
model = request.get("model", "")
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "default"
payload: Dict[str, Any] = {
"model": model,
"messages": list(request.get("messages", [])),
"temperature": request.get("temperature", 0.7),
}
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
if "tool_choice" in request:
payload["tool_choice"] = request["tool_choice"]
if "response_format" in request:
payload["response_format"] = request["response_format"]
if "max_tokens" in request:
payload["max_tokens"] = request["max_tokens"]
if "stream" in request:
payload["stream"] = request["stream"]
if "stop" in request:
payload["stop"] = request["stop"]
headers: Dict[str, str] = {
"Content-Type": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url}/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace")
try:
err_msg = extract_api_error_message(raw_err)
except Exception:
err_msg = raw_err
retry_after = http_err.headers.get("Retry-After")
if retry_after:
err_msg = f"{err_msg} (Retry-After: {retry_after})"
raise RuntimeError(f"NVIDIA API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"NVIDIA Transport Error: {exc}") from exc
self._reject_empty_answer(data)
return data
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Пустой ответ — это отказ, а не успех."""
choices = data.get("choices") or []
if not choices:
raise RuntimeError("NVIDIA вернул ответ без choices")
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if content:
return
finish = choices[0].get("finish_reason")
raise RuntimeError(f"NVIDIA вернул пустой ответ (finish_reason={finish})")
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
"""Request GET {base_url}/models and return the server's model list.
No invented/hardcoded model list: on error, fall back to
profile.preferred_models only.
"""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(models)
except Exception as exc:
logger.debug("Failed to discover models for nvidia profile %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or [])
def health_check(self, profile: RouterProfileConfig) -> bool:
"""Fast GET {base_url}/models probe. Returns True on success, False on error."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status in (200, 204)
except Exception:
return False
def classify_error(
self,
exc: Exception,
response_data: Optional[Dict[str, Any]] = None,
) -> ErrorClassification:
"""Classify execution failure into structured error category."""
err_msg = str(exc)
err_lower = err_msg.lower()
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
delay = 30
import re
m = re.search(r"retry-after:\s*(\d+)", err_lower)
if m:
delay = int(m.group(1))
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=delay,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=1800,
)
if any(k in err_lower for k in (
"connection refused", "connection error", "connect", "refused",
"timeout", "timed out", "502", "503", "504", "gateway",
"econnrefused", "econnreset", "transport error", "urlerror",
"winerror 10061", "nodename nor servname provided",
)):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=2,
)
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)

View file

@ -0,0 +1,279 @@
"""Ollama OpenAI-compatible and native API provider adapter."""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig
from .base_adapter import ErrorCategory, ErrorClassification, extract_api_error_message
from .local_adapter import LocalLLMAdapter
logger = logging.getLogger("hermes.router.adapter.ollama")
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
class OllamaAdapter(LocalLLMAdapter):
"""Adapter for local and remote Ollama LLM servers.
Supports both OpenAI-compatible endpoints (/v1/chat/completions, /v1/models)
and native Ollama endpoints (/api/tags).
"""
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or environment."""
url = (
profile.custom_base_url
or profile.auth_config.get("base_url")
or os.environ.get("OLLAMA_BASE_URL")
or os.environ.get("OLLAMA_HOST")
or DEFAULT_OLLAMA_BASE_URL
)
url_str = str(url).strip().rstrip("/")
if not url_str.startswith(("http://", "https://")):
url_str = f"http://{url_str}"
return url_str
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
"""Resolve optional API key from profile auth_config or environment."""
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
if key:
return str(key).strip()
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
val = os.environ.get(candidate, "").strip()
if val:
return val
return None
def _get_native_host(self, base_url: str) -> str:
"""Strip trailing /v1 from base_url to get native Ollama host."""
if base_url.endswith("/v1"):
return base_url[:-3]
return base_url
def _get_chat_url(self, base_url: str) -> str:
"""Get standard chat completions endpoint URL."""
if base_url.endswith("/v1"):
return f"{base_url}/chat/completions"
return f"{base_url}/v1/chat/completions"
def _get_models_url(self, base_url: str) -> str:
"""Get OpenAI-compatible models endpoint URL."""
if base_url.endswith("/v1"):
return f"{base_url}/models"
return f"{base_url}/v1/models"
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
chat_url = self._get_chat_url(base_url)
model = request.get("model", "")
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "llama3:latest"
messages = list(request.get("messages", []))
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": request.get("temperature", 0.7),
}
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
if "tool_choice" in request:
payload["tool_choice"] = request["tool_choice"]
if "response_format" in request:
payload["response_format"] = request["response_format"]
if "max_tokens" in request:
payload["max_tokens"] = request["max_tokens"]
if "stream" in request:
payload["stream"] = request["stream"]
if "stop" in request:
payload["stop"] = request["stop"]
headers: Dict[str, str] = {
"Content-Type": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
chat_url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace")
try:
err_msg = extract_api_error_message(raw_err)
except Exception:
err_msg = raw_err
raise RuntimeError(f"Ollama API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"Ollama Transport Error: {exc}") from exc
self._reject_empty_answer(data)
return data
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Reject empty completion responses."""
choices = data.get("choices") or []
if not choices:
raise RuntimeError("Ollama вернул ответ без choices")
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if content:
return
finish = choices[0].get("finish_reason")
if message.get("reasoning_content"):
raise RuntimeError(
"Ollama израсходовал лимит токенов на рассуждения и не выдал ответ "
f"(finish_reason={finish})."
)
raise RuntimeError(f"Ollama вернул пустой ответ (finish_reason={finish})")
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
"""Discover models via GET {base_url}/models or GET {host}/api/tags."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# 1. Try OpenAI-compatible /models endpoint
models_url = self._get_models_url(base_url)
try:
req = urllib.request.Request(models_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list) and items:
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Ollama /models discovery failed for %s: %s", profile.profile_id, exc)
# 2. Try native Ollama /api/tags endpoint
native_host = self._get_native_host(base_url)
try:
tags_url = f"{native_host}/api/tags"
req = urllib.request.Request(tags_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("models") or []
if isinstance(items, list) and items:
models = [
str(m.get("name") or m.get("model") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Ollama /api/tags discovery failed for %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or DEFAULT_OLLAMA_MODELS)
def health_check(self, profile: RouterProfileConfig) -> bool:
"""Probe /models or /api/tags endpoint. Returns True on success, False on error."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# 1. Probe /models
models_url = self._get_models_url(base_url)
try:
req = urllib.request.Request(models_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
if resp.status in (200, 204):
return True
except Exception:
pass
# 2. Probe /api/tags
native_host = self._get_native_host(base_url)
try:
tags_url = f"{native_host}/api/tags"
req = urllib.request.Request(tags_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
if resp.status in (200, 204):
return True
except Exception:
pass
return False
def classify_error(
self,
exc: Exception,
response_data: Optional[Dict[str, Any]] = None,
) -> ErrorClassification:
"""Classify execution failure into structured error category."""
err_msg = str(exc)
err_lower = err_msg.lower()
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=30,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=1800,
)
if any(k in err_lower for k in (
"connection refused", "connection error", "connect", "refused",
"timeout", "timed out", "502", "503", "504", "gateway",
"econnrefused", "econnreset", "transport error", "urlerror",
"winerror 10061", "nodename nor servname provided",
)):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=2,
)
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)

View file

@ -0,0 +1,268 @@
"""OpenRouter OpenAI-compatible provider adapter."""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message
logger = logging.getLogger("hermes.router.adapter.openrouter")
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
class OpenRouterAdapter(BaseProviderAdapter):
"""Adapter for OpenRouter's OpenAI-compatible chat completions API."""
_models_metadata: Dict[str, Dict[str, Any]] = {}
_context_window_cache: Dict[str, int] = {}
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or default."""
url = (
profile.custom_base_url
or profile.auth_config.get("base_url")
or os.environ.get("OPENROUTER_BASE_URL")
or DEFAULT_OPENROUTER_BASE_URL
)
url_str = str(url).strip().rstrip("/")
if not url_str.startswith(("http://", "https://")):
url_str = f"https://{url_str}"
return url_str
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
"""Resolve API key from profile auth_config or environment."""
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
if key:
return str(key).strip()
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
val = os.environ.get(candidate, "").strip()
if val:
return val
return None
def _build_headers(self, api_key: Optional[str] = None) -> Dict[str, str]:
"""Build standard headers with OpenRouter attribution headers."""
referer = (
os.environ.get("OPENROUTER_HTTP_REFERER")
or os.environ.get("HERMES_REFERER")
or "https://github.com/ochenstarik-ui/hermes-hub"
)
title = (
os.environ.get("OPENROUTER_APP_TITLE")
or os.environ.get("OPENROUTER_TITLE")
or "Hermes Hub"
)
headers: Dict[str, str] = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
"HTTP-Referer": referer,
"X-OpenRouter-Title": title,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def get_context_window(
self,
profile: RouterProfileConfig,
model: Optional[str] = None,
) -> Optional[int]:
"""Fetch actual context_window / max_context_length from profile or discovery cache."""
cache_key = f"{profile.profile_id}:{model or 'default'}"
if cache_key in self._context_window_cache:
return self._context_window_cache[cache_key]
if model and model in self._context_window_cache:
return self._context_window_cache[model]
return None
def get_model_metadata(self, model_id: str) -> Optional[Dict[str, Any]]:
"""Return cached metadata for model ID if available."""
return self._models_metadata.get(model_id)
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
model = request.get("model", "")
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "default"
payload: Dict[str, Any] = {
"model": model,
"messages": list(request.get("messages", [])),
"temperature": request.get("temperature", 0.7),
}
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
if "tool_choice" in request:
payload["tool_choice"] = request["tool_choice"]
if "response_format" in request:
payload["response_format"] = request["response_format"]
if "max_tokens" in request:
payload["max_tokens"] = request["max_tokens"]
if "stream" in request:
payload["stream"] = request["stream"]
if "stop" in request:
payload["stop"] = request["stop"]
headers = self._build_headers(api_key)
req = urllib.request.Request(
f"{base_url}/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as http_err:
raw_err = http_err.read().decode("utf-8", errors="replace")
try:
err_msg = extract_api_error_message(raw_err)
except Exception:
err_msg = raw_err
raise RuntimeError(f"OpenRouter API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"OpenRouter Transport Error: {exc}") from exc
self._reject_empty_answer(data)
return data
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Пустой ответ — это отказ, а не успех."""
choices = data.get("choices") or []
if not choices:
raise RuntimeError("OpenRouter вернул ответ без choices")
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if content:
return
finish = choices[0].get("finish_reason")
raise RuntimeError(f"OpenRouter вернул пустой ответ (finish_reason={finish})")
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
"""Request GET {base_url}/models and return the server's model list.
Extracts context_length and display_name metadata when provided by the API.
No invented/hardcoded model list: on error, fall back to profile.preferred_models.
"""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers = self._build_headers(api_key)
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list) and items:
models = []
for m in items:
if isinstance(m, dict):
m_id = str(m.get("id") or m.get("name") or "")
if not m_id:
continue
display_name = str(m.get("name") or m.get("display_name") or m_id)
ctx_len = m.get("context_length") or m.get("context_window") or m.get("max_context_length")
meta: Dict[str, Any] = {
"id": m_id,
"display_name": display_name,
}
if ctx_len is not None:
try:
val = int(ctx_len)
meta["context_length"] = val
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = val
self._context_window_cache[m_id] = val
except (ValueError, TypeError):
pass
self._models_metadata[m_id] = meta
models.append(m_id)
elif isinstance(m, str) and m:
models.append(m)
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Failed to discover models for openrouter profile %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or [])
def health_check(self, profile: RouterProfileConfig) -> bool:
"""Fast GET {base_url}/models probe. Returns True on success, False on error."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers = self._build_headers(api_key)
req = urllib.request.Request(
f"{base_url}/models",
headers=headers,
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status in (200, 204)
except Exception:
return False
def classify_error(
self,
exc: Exception,
response_data: Optional[Dict[str, Any]] = None,
) -> ErrorClassification:
"""Classify execution failure into structured error category."""
err_msg = str(exc)
err_lower = err_msg.lower()
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=30,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=1800,
)
if any(k in err_lower for k in (
"connection refused", "connection error", "connect", "refused",
"timeout", "timed out", "502", "503", "504", "gateway",
"econnrefused", "econnreset", "transport error", "urlerror",
"winerror 10061", "nodename nor servname provided",
)):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=2,
)
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)

View file

@ -14,6 +14,7 @@ import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from antigravity_provider import paths
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
from antigravity_provider.router.role_registry import RoleRegistry
from antigravity_provider.router.router_config import (
@ -67,6 +68,22 @@ class AutoAssigner:
"""Get human-readable display name, logical role, and tier for a profile."""
if profile_id in DEFAULT_SLOT_ROLES:
return DEFAULT_SLOT_ROLES[profile_id]
prov_map = {
"ag": "Antigravity",
"codex": "Codex",
"opengo": "OpenCode",
"claude": "Claude",
"grok": "Grok",
"local": "Локальный сервер",
"openrouter": "OpenRouter",
"nvidia": "NVIDIA NIM",
"ollama": "Ollama",
"vllm": "vLLM",
}
parts = profile_id.split("-")
if len(parts) == 2 and parts[0] in prov_map and parts[1].isdigit():
clean_name = f"{prov_map[parts[0]]} {parts[1]}"
return (clean_name, "worker", "primary")
clean_name = profile_id.replace("-", " ").title()
return (clean_name, "worker", "primary")
@ -104,88 +121,57 @@ class AutoAssigner:
def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]:
"""Find the optimal free internal profile slot for a provider.
When all predefined candidate slots are authenticated/occupied, dynamically
generates the next free profile ID without capping account count (P0-1).
Generates clean numbered slots starting from 1 (codex-1, ag-1, claude-1,
grok-1, opengo-1, local-1, openrouter-1, nvidia-1, ollama-1) upon first addition.
"""
config = load_router_config()
provider_norm = (provider or "").strip().lower()
provider_slots = {
"antigravity": [
"ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4",
"ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"
],
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
"codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
"opencode": ["opengo-1", "opengo-2", "opengo-3"],
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
"anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"],
"grok": ["grok-orch", "grok-worker-1", "grok-worker-2"],
"xai": ["grok-orch", "grok-worker-1", "grok-worker-2"],
"local": ["local-1", "local-2"],
"local-llm": ["local-1", "local-2"],
"llama.cpp": ["local-1", "local-2"],
"ollama": ["local-1", "local-2"],
"vllm": ["local-1", "local-2"],
}
def _get_prefix(prov: str) -> str:
p = (prov or "").strip().lower()
if p in ("antigravity", "google-antigravity", "agy"):
return "ag"
if p in ("openai-codex", "codex", "openai"):
return "codex"
if p in ("opencode-go", "opencode", "opengo"):
return "opengo"
if p in ("claude", "anthropic"):
return "claude"
if p in ("grok", "xai"):
return "grok"
if p in ("local", "local-llm", "llama.cpp"):
return "local"
if p in ("openrouter",):
return "openrouter"
if p in ("nvidia", "nvidia-nim"):
return "nvidia"
if p in ("ollama",):
return "ollama"
if p in ("vllm",):
return "vllm"
return p
candidates = list(provider_slots.get(provider_norm, []))
prefix = _get_prefix(provider_norm)
# Priority based on requested role
if requested_role == "manager":
if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates:
candidates.remove("codex-orch")
candidates.insert(0, "codex-orch")
elif provider_norm == "antigravity" and "ag-orch-fallback" in candidates:
candidates.remove("ag-orch-fallback")
candidates.insert(0, "ag-orch-fallback")
# 1. First check existing candidate profiles already in config
for pid in candidates:
pcfg = config.get_profile(pid)
if not pcfg:
continue
status = ProfileAuthManager.get_profile_status(provider, pid)
if not status.get("authenticated"):
return pid
for pid, pcfg in config.profiles.items():
if _get_prefix(pcfg.provider) == prefix or pcfg.provider == provider_norm:
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
if not status.get("authenticated"):
return pid
# 2. Check remaining predefined slots and ensure definition
for pid in candidates:
status = ProfileAuthManager.get_profile_status(provider, pid)
# 2. Dynamically generate clean numbered slots (codex-1, ag-1, claude-1, etc.)
for i in range(1, 200):
candidate_pid = f"{prefix}-{i}"
if candidate_pid in config.profiles:
pcfg = config.profiles[candidate_pid]
status = ProfileAuthManager.get_profile_status(pcfg.provider, candidate_pid)
if status.get("authenticated"):
continue
status = ProfileAuthManager.get_profile_status(provider, candidate_pid)
if not status.get("authenticated"):
AutoAssigner.ensure_profile_definition(provider, pid)
return pid
# 3. Predefined slots occupied: dynamically generate unlimited candidates
def _generate_candidates(prov: str):
p = prov.lower()
if p == "antigravity":
for i in range(5, 200):
yield f"ag-w{i}"
elif p in ("openai-codex", "codex"):
for i in range(4, 200):
yield f"codex-{i}"
elif p in ("opencode-go", "opencode"):
for i in range(4, 200):
yield f"opengo-{i}"
elif p in ("claude", "anthropic"):
for i in range(3, 200):
yield f"claude-worker-{i}"
elif p in ("grok", "xai"):
for i in range(3, 200):
yield f"grok-worker-{i}"
elif p in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
for i in range(3, 200):
yield f"local-{i}"
else:
for i in range(1, 200):
yield f"{p}-{i}"
for dynamic_pid in _generate_candidates(provider_norm):
status = ProfileAuthManager.get_profile_status(provider, dynamic_pid)
if not status.get("authenticated"):
AutoAssigner.ensure_profile_definition(provider, dynamic_pid)
return dynamic_pid
AutoAssigner.ensure_profile_definition(provider, candidate_pid)
return candidate_pid
return None
@ -206,15 +192,23 @@ class AutoAssigner:
capabilities_map = {
"grok": ["reasoning", "coding", "research"],
"xai": ["reasoning", "coding", "research"],
"claude": ["reasoning", "coding", "review"],
"anthropic": ["reasoning", "coding", "review"],
"opencode-go": ["coding", "research", "fast"],
"opencode": ["coding", "research", "fast"],
"openai-codex": ["coding", "reasoning"],
"codex": ["coding", "reasoning"],
"antigravity": ["coding", "reasoning", "research", "fast"],
"google-antigravity": ["coding", "reasoning", "research", "fast"],
"local": ["reviewer", "coding", "reasoning", "fast", "research"],
"local-llm": ["reviewer", "coding", "reasoning", "fast", "research"],
"llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"],
"ollama": ["reviewer", "coding", "reasoning", "fast", "research"],
"vllm": ["reviewer", "coding", "reasoning", "fast", "research"],
"openrouter": ["coding", "reasoning", "research", "fast"],
"nvidia": ["coding", "reasoning", "fast"],
"nvidia-nim": ["coding", "reasoning", "fast"],
}
capabilities = capabilities_map.get(provider, ["coding", "reasoning"])
@ -273,6 +267,28 @@ class AutoAssigner:
"""Assign a profile to a canonical router role, updating fallback chains and persisting config."""
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
inferred_prov = None
for prefix, prov in [
("ag-", "antigravity"),
("codex-", "openai-codex"),
("opengo-", "opencode-go"),
("claude-", "claude"),
("grok-", "grok"),
("local-", "local"),
("openrouter-", "openrouter"),
("nvidia-", "nvidia"),
("ollama-", "ollama"),
("vllm-", "vllm"),
]:
if profile_id.startswith(prefix):
inferred_prov = prov
break
if inferred_prov:
AutoAssigner.ensure_profile_definition(inferred_prov, profile_id)
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
return False, f"Профиль '{profile_id}' не найден"
@ -311,6 +327,31 @@ class AutoAssigner:
@staticmethod
def auto_assign_all() -> Dict[str, Any]:
"""Automatically distribute all authenticated profiles across canonical router roles (P0-4)."""
config = load_router_config()
# Scan on-disk profiles and ensure their definition in config.profiles
prov_prefix_map = {
"antigravity": "agy_profiles",
"openai-codex": "codex_profiles",
"opencode-go": "opengo_profiles",
"claude": "claude_profiles",
"grok": "grok_profiles",
"local": "local_profiles",
"openrouter": "openrouter_profiles",
"nvidia": "nvidia_profiles",
"ollama": "ollama_profiles",
"vllm": "vllm_profiles",
}
for prov, subdir in prov_prefix_map.items():
base_dir = paths.get_hermes_home() / subdir
if base_dir.is_dir():
for p_entry in base_dir.iterdir():
if p_entry.is_dir():
pid = p_entry.name
if pid not in config.profiles:
st = ProfileAuthManager.get_profile_status(prov, pid)
if st.get("authenticated"):
AutoAssigner.ensure_profile_definition(prov, pid)
config = load_router_config()
authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = []
for pid, pcfg in config.profiles.items():

View file

@ -34,6 +34,9 @@ def print_router_status() -> int:
print("-" * 80)
for rname, rpolicy in sorted(config.roles.items()):
if not rpolicy.preferred_chain:
print(f"{rname:<16} {'(не назначен)':<18} {'-':<15} {'not_configured':<18} {'-':<10}")
continue
for idx, pid in enumerate(rpolicy.preferred_chain):
pconfig = config.get_profile(pid)
if not pconfig:
@ -222,11 +225,6 @@ def test_profile_cli(profile_id: str) -> int:
def simulate_quota_cli(profile_id: str, model_family: Optional[str] = None, duration: int = 600) -> int:
"""Simulate quota exhaustion on a profile for testing."""
engine = get_router_engine()
pconfig = engine.config.get_profile(profile_id)
if not pconfig:
print(f"[ERROR] Profile '{profile_id}' not found.")
return 1
engine.health.simulate_quota(profile_id, model_family=model_family, duration=duration)
print(f"[OK] Simulated quota exhaustion activated for profile '{profile_id}' for {duration} seconds.")
print("Use `hermes router clear-cooldown` to restore normal state.")
@ -254,7 +252,7 @@ def print_diagnostics_cli() -> int:
reasons.append(f"Отсутствуют зависимости: {', '.join(missing_deps)}")
has_fatal_error = True
else:
print("[PASS] Зависимости venv: все необходимые пакеты установлены (customtkinter, Pillow, psutil, pyyaml)")
print("[PASS] Зависимости venv: все необходимые пакеты установлены (fastapi, uvicorn, psutil, pyyaml)")
# 2. Deployed Plugin Freshness Check
hermes_home = paths.get_hermes_home()
@ -478,8 +476,10 @@ def main(argv: Optional[list[str]] = None) -> int:
args = parser.parse_args(argv)
if args.subcommand in ("hub", "cockpit", "gui"):
from antigravity_provider.router.hermes_hub_app import launch_hub
launch_hub()
from antigravity_provider.router.web.server import run_web_server
port = getattr(args, "port", None)
no_browser = getattr(args, "no_browser", False)
run_web_server(port=port, open_browser=not no_browser)
return 0
elif args.subcommand == "status":
return print_router_status()

File diff suppressed because it is too large Load diff

View file

@ -58,15 +58,15 @@ def show_native_error(title: str, message: str) -> None:
REQUIRED_PACKAGES: Dict[str, str] = {
"customtkinter": "customtkinter>=6.0.0",
"PIL": "pillow>=10.0.0",
"fastapi": "fastapi>=0.110.0",
"uvicorn": "uvicorn>=0.28.0",
"psutil": "psutil>=5.9.0",
"yaml": "pyyaml>=6.0.1",
}
def check_missing_dependencies() -> List[str]:
"""Check which required UI / system packages are currently unimportable."""
"""Check which required Web / system packages are currently unimportable."""
missing = []
for mod_name, pkg_spec in REQUIRED_PACKAGES.items():
try:
@ -118,7 +118,7 @@ def self_heal_dependencies(missing_packages: List[str]) -> Tuple[bool, str]:
def bootstrap_and_launch() -> None:
"""Bootstrap entry point: log startup, verify dependencies, and launch Hermes Hub GUI."""
"""Bootstrap entry point: log startup, verify dependencies, and launch Hermes Hub Web."""
log_startup("=== Hermes Hub Launcher Bootstrap initiated ===")
log_startup(f"Python: {sys.executable} (version {sys.version.split()[0]})")
log_startup(f"Working Directory: {os.getcwd()}")
@ -132,22 +132,22 @@ def bootstrap_and_launch() -> None:
log_path = get_startup_log_path()
show_native_error(
"Hermes Hub — Ошибка компонентов",
"Не удалось автоматически установить необходимые компоненты графического интерфейса (customtkinter / Pillow / psutil).\n\n"
"Не удалось автоматически установить необходимые компоненты веб-интерфейса (fastapi / uvicorn / psutil / pyyaml).\n\n"
f"Детали ошибки: {detail}\n\n"
f"Лог запуска: {log_path}\n\n"
"Вы можете установить их вручную командой:\n"
f"{sys.executable} -m pip install customtkinter pillow psutil pyyaml",
f"{sys.executable} -m pip install fastapi uvicorn psutil pyyaml",
)
sys.exit(1)
# 2. Launch GUI with full exception capture
# 2. Launch Web GUI with full exception capture
try:
log_startup("Importing hermes_hub_app module...")
from antigravity_provider.router.hermes_hub_app import launch_hub
log_startup("Importing web.server module...")
from antigravity_provider.router.web.server import run_web_server
log_startup("Executing launch_hub()...")
launch_hub()
log_startup("Hermes Hub GUI closed normally.")
log_startup("Executing run_web_server()...")
run_web_server(open_browser=True)
log_startup("Hermes Hub Web closed normally.")
except Exception as exc:
tb = traceback.format_exc()
log_startup(f"FATAL EXCEPTION during launch:\n{tb}")

View file

@ -322,7 +322,11 @@ class ProfileAuthManager:
if base_url:
try:
from antigravity_provider.router.router_config import load_router_config, save_router_config
from antigravity_provider.router.auto_assigner import AutoAssigner
rcfg = load_router_config()
if profile_id not in rcfg.profiles:
AutoAssigner.ensure_profile_definition(provider, profile_id)
rcfg = load_router_config()
if profile_id in rcfg.profiles:
rcfg.profiles[profile_id].custom_base_url = str(base_url).strip()
if auth_data.get("models") and isinstance(auth_data["models"], list):

View file

@ -39,7 +39,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_orchestrator=True,
capabilities=["orchestrator", "reasoning", "management", "planning"],
fallback_capabilities=["orchestrator", "reasoning"],
default_preferred_chain=["codex-orch", "ag-orch-fallback", "claude-orch", "grok-orch", "opengo-3"],
default_preferred_chain=[],
default_model="gemini-3.7-flash",
max_failover_attempts=3,
tier="core",
@ -52,7 +52,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
fallback_capabilities=["coding"],
default_preferred_chain=["ag-w1", "codex-worker-1", "claude-worker-1", "opengo-1"],
default_preferred_chain=[],
default_model="gemini-3.7-flash",
max_failover_attempts=3,
tier="core",
@ -65,7 +65,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["coding", "developer-2", "reviewer", "tools"],
fallback_capabilities=["coding", "reviewer"],
default_preferred_chain=["ag-w2", "codex-worker-2", "grok-worker-1", "opengo-3"],
default_preferred_chain=[],
default_model="gemini-3.1-pro-high",
max_failover_attempts=3,
tier="core",
@ -78,7 +78,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
fallback_capabilities=["reviewer", "coding"],
default_preferred_chain=["ag-w3", "ag-w2", "claude-worker-2", "codex-worker-2", "opengo-2"],
default_preferred_chain=[],
default_model="claude-opus-4-6-thinking",
max_failover_attempts=3,
tier="core",
@ -91,7 +91,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["researcher", "research", "search", "long_context"],
fallback_capabilities=["research", "search"],
default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="core",
),
@ -103,7 +103,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["tester", "testing", "fast", "automation"],
fallback_capabilities=["fast", "testing"],
default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="qa_doc",
),
@ -115,7 +115,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["tech-writer", "documentation", "reasoning", "structured_output"],
fallback_capabilities=["documentation", "reasoning"],
default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="qa_doc",
),
@ -127,7 +127,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["analyst", "reasoning", "research", "planning"],
fallback_capabilities=["reasoning", "research"],
default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="qa_doc",
),
@ -165,7 +165,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["integration-expert", "integration", "coding", "networking"],
fallback_capabilities=["integration", "coding"],
default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="expert",
),
@ -177,7 +177,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["security-expert", "security", "code-reviewer", "audit"],
fallback_capabilities=["security", "code-reviewer"],
default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="expert",
),
@ -189,7 +189,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True,
capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"],
fallback_capabilities=["dependency-agent", "preflight"],
default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"],
default_preferred_chain=[],
max_failover_attempts=3,
tier="qa_doc",
),

View file

@ -21,6 +21,7 @@ class RouterProfileConfig:
enabled: bool = True
max_concurrency: int = 1 # 1 for stateful process, >1 for stateless REST
custom_base_url: Optional[str] = None
request_options: dict[str, Any] = field(default_factory=dict)
@dataclass
@ -67,219 +68,14 @@ class RouterConfig:
def get_default_router_config() -> RouterConfig:
"""Generate default built-in multi-provider configuration (16 profiles across 3 providers)."""
profiles: dict[str, RouterProfileConfig] = {
# 1. Codex Pool (3 accounts)
"codex-orch": RouterProfileConfig(
profile_id="codex-orch",
provider="openai-codex",
account_id="codex-acc-1",
capabilities=["orchestrator", "coding", "reasoning"],
preferred_models=["gpt-4o", "o3-mini", "codex"],
fallback_models=["gpt-4o-mini"],
max_concurrency=1,
),
"codex-worker-1": RouterProfileConfig(
profile_id="codex-worker-1",
provider="openai-codex",
account_id="codex-acc-2",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["gpt-4o", "o3-mini", "codex"],
max_concurrency=1,
),
"codex-worker-2": RouterProfileConfig(
profile_id="codex-worker-2",
provider="openai-codex",
account_id="codex-acc-3",
capabilities=["coding", "coder-secondary", "reviewer", "review"],
preferred_models=["gpt-4o", "o3-mini", "codex"],
max_concurrency=1,
),
# 2. Antigravity Pool (10 accounts, 7 active, 3 cold)
"ag-orch-fallback": RouterProfileConfig(
profile_id="ag-orch-fallback",
provider="antigravity",
account_id="ag-acc-orch",
capabilities=["orchestrator", "reasoning", "coding"],
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-w1": RouterProfileConfig(
profile_id="ag-w1",
provider="antigravity",
account_id="ag-acc-w1",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-w2": RouterProfileConfig(
profile_id="ag-w2",
provider="antigravity",
account_id="ag-acc-w2",
capabilities=["coding", "coder-secondary", "reviewer", "review"],
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-w3": RouterProfileConfig(
profile_id="ag-w3",
provider="antigravity",
account_id="ag-acc-w3",
capabilities=["research", "reasoning", "search"],
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6"],
max_concurrency=1,
),
"ag-w4": RouterProfileConfig(
profile_id="ag-w4",
provider="antigravity",
account_id="ag-acc-w4",
capabilities=["coding", "reasoning", "fast"],
preferred_models=["gemini-3.5-flash", "gemini-3.7-flash"],
max_concurrency=1,
),
"ag-spare-1": RouterProfileConfig(
profile_id="ag-spare-1",
provider="antigravity",
account_id="ag-acc-sp1",
capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"],
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-spare-2": RouterProfileConfig(
profile_id="ag-spare-2",
provider="antigravity",
account_id="ag-acc-sp2",
capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"],
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-cold-1": RouterProfileConfig(
profile_id="ag-cold-1",
provider="antigravity",
account_id="ag-acc-c1",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
"ag-cold-2": RouterProfileConfig(
profile_id="ag-cold-2",
provider="antigravity",
account_id="ag-acc-c2",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
"ag-cold-3": RouterProfileConfig(
profile_id="ag-cold-3",
provider="antigravity",
account_id="ag-acc-c3",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
# 3. OpenCode Go Pool (3 accounts)
"opengo-1": RouterProfileConfig(
profile_id="opengo-1",
provider="opencode-go",
account_id="opengo-acc-1",
capabilities=["coding", "fast", "multimodal"],
preferred_models=["deepseek-r1", "qwen-2.5-coder-32b", "deepseek-v3"],
max_concurrency=5,
),
"opengo-2": RouterProfileConfig(
profile_id="opengo-2",
provider="opencode-go",
account_id="opengo-acc-2",
capabilities=["research", "coding", "multimodal"],
preferred_models=["deepseek-v3", "qwen-2.5-coder-32b", "deepseek-r1"],
max_concurrency=5,
),
"opengo-3": RouterProfileConfig(
profile_id="opengo-3",
provider="opencode-go",
account_id="opengo-acc-3",
capabilities=["fallback", "coding", "reasoning"],
preferred_models=["deepseek-r1", "deepseek-v3", "qwen-2.5-coder-32b"],
max_concurrency=5,
),
# 4. Claude Pool (3 accounts)
"claude-orch": RouterProfileConfig(
profile_id="claude-orch",
provider="claude",
account_id="claude-acc-1",
capabilities=["orchestrator", "coding", "reasoning"],
preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"],
max_concurrency=2,
),
"claude-worker-1": RouterProfileConfig(
profile_id="claude-worker-1",
provider="claude",
account_id="claude-acc-2",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"],
max_concurrency=2,
),
"claude-worker-2": RouterProfileConfig(
profile_id="claude-worker-2",
provider="claude",
account_id="claude-acc-3",
capabilities=["coding", "coder-secondary", "reviewer", "review"],
preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku"],
max_concurrency=2,
),
# 5. Grok Pool (3 accounts)
"grok-orch": RouterProfileConfig(
profile_id="grok-orch",
provider="grok",
account_id="grok-acc-1",
capabilities=["orchestrator", "coding", "reasoning"],
preferred_models=["grok-3", "grok-3-mini", "grok-4.5"],
max_concurrency=2,
),
"grok-worker-1": RouterProfileConfig(
profile_id="grok-worker-1",
provider="grok",
account_id="grok-acc-2",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["grok-3", "grok-3-mini", "grok-4.5"],
max_concurrency=2,
),
"grok-worker-2": RouterProfileConfig(
profile_id="grok-worker-2",
provider="grok",
account_id="grok-acc-3",
capabilities=["research", "reasoning", "fast"],
preferred_models=["grok-3", "grok-3-mini"],
max_concurrency=2,
),
# 6. Local LLM Pool (2 accounts)
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
account_id="local-acc-1",
capabilities=["code-reviewer", "reviewer", "coder-secondary", "reasoning", "coding"],
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"],
max_concurrency=1,
),
"local-2": RouterProfileConfig(
profile_id="local-2",
provider="local",
account_id="local-acc-2",
capabilities=["tester", "fast", "research", "coding"],
preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"],
max_concurrency=1,
),
}
"""Generate default clean multi-provider configuration (0 profiles, 13 canonical roles with empty chains)."""
roles = RoleRegistry.get_default_role_policies()
return RouterConfig(
enabled=True,
default_role="manager",
roles=roles,
profiles=profiles,
profiles={},
)
@ -307,6 +103,9 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
max_concurrency = int(pdata.get("max_concurrency", 1))
if provider == "local":
max_concurrency = 1
req_opts = pdata.get("request_options")
if not isinstance(req_opts, dict):
req_opts = {}
profiles[pid] = RouterProfileConfig(
profile_id=pid,
provider=provider,
@ -318,6 +117,7 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
enabled=bool(pdata.get("enabled", True)),
max_concurrency=max_concurrency,
custom_base_url=pdata.get("custom_base_url"),
request_options=dict(req_opts),
)
roles_raw = data.get("roles", {})
@ -359,24 +159,18 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
if quota_threshold_action not in ("notify", "switch"):
quota_threshold_action = "notify"
# Automatic Idempotent Migration (P0-0.1)
# Merge missing default profiles and roles into loaded user configuration
# Automatic Idempotent Migration (A41 Clean Install)
# Merge missing default roles into loaded user configuration without injecting fake profiles
default_cfg = get_default_router_config()
migration_needed = False
new_profiles_added: list[str] = []
new_roles_added: list[str] = []
if not profiles:
profiles = default_cfg.profiles
else:
for def_pid, def_pcfg in default_cfg.profiles.items():
if def_pid not in profiles:
profiles[def_pid] = def_pcfg
new_profiles_added.append(def_pid)
migration_needed = True
profiles = {}
if not roles:
roles = default_cfg.roles
migration_needed = True
else:
# Сначала переименование старых ролей в канонические, и только
# потом дополнение недостающими.
@ -483,6 +277,8 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None)
}
if pcfg.custom_base_url:
profiles_data[pid]["custom_base_url"] = pcfg.custom_base_url
if pcfg.request_options:
profiles_data[pid]["request_options"] = pcfg.request_options
roles_data = {}
for rname, rpol in config.roles.items():

View file

@ -1,8 +0,0 @@
"""Hermes Hub UI Package — Native Dark-First Design System."""
from __future__ import annotations
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
__all__ = ["Theme", "AssetManager"]

File diff suppressed because it is too large Load diff

View file

@ -1,228 +0,0 @@
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
try:
from PIL import Image, ImageDraw
except ImportError:
Image = None
ImageDraw = None
try:
import customtkinter as ctk
except ImportError:
import unittest.mock as _mock
ctk = _mock.MagicMock()
class AssetManager:
_instance: Optional[AssetManager] = None
_image_cache: Dict[str, ctk.CTkImage] = {}
_pil_cache: Dict[str, Any] = {}
def __init__(self):
self.root_dir = self._find_repo_root()
self.branding_dir = self.root_dir / "assets" / "branding"
self.providers_dir = self.root_dir / "assets" / "providers"
self.logo_dir = self.branding_dir / "logo"
self.app_dir = self.branding_dir / "app"
self.splash_dir = self.branding_dir / "splash"
self.icons_dir = self.branding_dir / "icons"
@classmethod
def get(cls) -> AssetManager:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _find_repo_root(self) -> Path:
from antigravity_provider.paths import get_repo_root
return get_repo_root()
def get_ico_path(self) -> str:
ico = self.app_dir / "HermesHub.ico"
if ico.exists():
return str(ico)
alt = self.root_dir / "launcher" / "HermesHub.ico"
if alt.exists():
return str(alt)
return ""
@classmethod
def clear_cache(cls):
cls._image_cache.clear()
cls._pil_cache.clear()
def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]:
logo_path = self.logo_dir / "logo_approved.png"
if not logo_path.exists():
logo_path = self.logo_dir / "logo_256.png"
if not logo_path.exists():
logo_path = self.logo_dir / "logo_master.png"
if not logo_path.exists():
logo_path = self.branding_dir / "source" / "Hermes Hub.png"
if logo_path.exists():
try:
pil_img = Image.open(logo_path).convert("RGBA")
from antigravity_provider.router.ui.theme import Theme
transparent = pil_img.copy()
pixels = []
tint_gold = Theme.current_scheme in {"dark", "hybrid"}
dark_fill = tuple(int(Theme.BG_SIDEBAR[index : index + 2], 16) for index in (1, 3, 5))
pixel_data = (
transparent.get_flattened_data()
if hasattr(transparent, "get_flattened_data")
else transparent.getdata()
)
for red, green, blue, alpha in pixel_data:
if red > 232 and green > 230 and blue > 224:
pixels.append((red, green, blue, 0))
elif tint_gold and max(red, green, blue) < 105:
pixels.append((*dark_fill, alpha))
else:
pixels.append((red, green, blue, alpha))
transparent.putdata(pixels)
return ctk.CTkImage(light_image=transparent, dark_image=transparent, size=size)
except Exception:
return None
return None
def get_provider_image(self, provider: str, size: Tuple[int, int] = (20, 20)) -> Optional[ctk.CTkImage]:
"""Fetch cached CTkImage for AI providers (antigravity, openai, opencode)."""
prov_clean = provider.lower()
if "antigravity" in prov_clean:
key_name = "antigravity"
elif "codex" in prov_clean or "openai" in prov_clean:
key_name = "openai"
elif "opencode" in prov_clean:
key_name = "opencode"
else:
key_name = "antigravity"
cache_key = f"prov_{key_name}_{size[0]}x{size[1]}"
if cache_key in self._image_cache:
return self._image_cache[cache_key]
prov_folder = self.providers_dir / key_name
# Find best size matching
target_file = prov_folder / f"{key_name}_{size[0]}.png"
if not target_file.exists():
target_file = prov_folder / f"{key_name}_32.png"
if not target_file.exists():
target_file = prov_folder / f"{key_name}_master.png"
if target_file.exists():
try:
pil_img = Image.open(target_file).convert("RGBA")
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size)
except Exception:
return None
return None
def get_nav_icon(self, name: str, size: int = 20) -> Optional[ctk.CTkImage]:
"""Return a consistent gold outline icon for the sidebar."""
if Image is None or ImageDraw is None:
return None
from antigravity_provider.router.ui.theme import Theme
cache_key = f"nav_{Theme.current_scheme}_{name}_{size}"
if cache_key in self._image_cache:
return self._image_cache[cache_key]
scale = 4
canvas_size = size * scale
image = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
color = Theme.TEXT_ACCENT
width = 2 * scale
def points(values):
return tuple(tuple(int(coord * scale) for coord in point) for point in values)
def line(values, **kwargs):
draw.line(points(values), fill=color, width=width, joint="curve", **kwargs)
def ellipse(box):
draw.ellipse(tuple(int(value * scale) for value in box), outline=color, width=width)
def rectangle(box, radius=0):
scaled = tuple(int(value * scale) for value in box)
if radius:
draw.rounded_rectangle(scaled, radius=radius * scale, outline=color, width=width)
else:
draw.rectangle(scaled, outline=color, width=width)
if name == "overview":
line(((3, 10), (10, 4), (17, 10)))
line(((5, 9), (5, 17), (15, 17), (15, 9)))
line(((8, 17), (8, 12), (12, 12), (12, 17)))
elif name == "team":
ellipse((3, 4, 9, 10))
ellipse((11, 5, 16, 10))
draw.arc((1 * scale, 9 * scale, 12 * scale, 19 * scale), 190, 350, fill=color, width=width)
draw.arc((8 * scale, 10 * scale, 19 * scale, 19 * scale), 190, 350, fill=color, width=width)
elif name == "accounts":
draw.ellipse((3 * scale, 3 * scale, 17 * scale, 8 * scale), outline=color, width=width)
line(((3, 5.5), (3, 15), (5, 17), (15, 17), (17, 15), (17, 5.5)))
draw.arc((3 * scale, 8 * scale, 17 * scale, 13 * scale), 0, 180, fill=color, width=width)
elif name == "routing":
ellipse((2, 8, 6, 12))
ellipse((8, 2, 12, 6))
ellipse((14, 8, 18, 12))
ellipse((8, 14, 12, 18))
line(((6, 10), (14, 10)))
line(((10, 6), (10, 14)))
elif name == "orchestrator":
ellipse((6, 6, 14, 14))
for box in ((2, 2, 5, 5), (15, 2, 18, 5), (2, 15, 5, 18), (15, 15, 18, 18)):
ellipse(box)
line(((5, 5), (7, 7)))
line(((15, 5), (13, 7)))
line(((5, 15), (7, 13)))
line(((15, 15), (13, 13)))
elif name == "providers":
draw.arc((2 * scale, 7 * scale, 18 * scale, 17 * scale), 175, 365, fill=color, width=width)
draw.arc((5 * scale, 2 * scale, 14 * scale, 13 * scale), 190, 350, fill=color, width=width)
line(((4, 16), (16, 16)))
elif name == "quotas":
line(((10, 2), (17, 5), (16, 13), (10, 18), (4, 13), (3, 5), (10, 2)))
line(((7, 10), (9, 12), (13, 7)))
elif name == "analytics":
rectangle((3, 11, 6, 17))
rectangle((8.5, 7, 11.5, 17))
rectangle((14, 3, 17, 17))
line(((2, 18), (18, 18)))
elif name == "health":
line(((2, 11), (6, 11), (8, 5), (11, 16), (13, 9), (18, 9)))
elif name == "logs":
rectangle((4, 2, 16, 18), radius=1)
line(((7, 7), (13, 7)))
line(((7, 11), (13, 11)))
line(((7, 15), (11, 15)))
elif name == "incidents":
line(((10, 2), (18, 17), (2, 17), (10, 2)))
line(((10, 7), (10, 12)))
ellipse((9.3, 14, 10.7, 15.4))
elif name == "settings":
ellipse((7, 7, 13, 13))
for angle in range(0, 360, 45):
import math
start = (10 + 5 * math.cos(math.radians(angle)), 10 + 5 * math.sin(math.radians(angle)))
end = (10 + 8 * math.cos(math.radians(angle)), 10 + 8 * math.sin(math.radians(angle)))
line((start, end))
else:
ellipse((3, 3, 17, 17))
line(((10, 8), (10, 15)))
ellipse((9.3, 5, 10.7, 6.4))
image = image.resize((size, size), Image.Resampling.LANCZOS)
result = ctk.CTkImage(light_image=image, dark_image=image, size=(size, size))
self._image_cache[cache_key] = result
return result

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
"""Non-blocking UI adapter for the backend model-discovery cache.
The UI never discovers models synchronously and never invents model IDs. The
adapter deliberately tolerates the small naming differences used by backend
revisions so the presentation layer can be merged independently from A9.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass(frozen=True)
class CachedModels:
provider: str
models: tuple[str, ...] = ()
fetched_at: str = ""
is_stale: bool = True
unavailable_reason: str = "Список моделей ещё не получен"
def _service() -> Any:
try:
from antigravity_provider.router.model_discovery import ModelDiscoveryService
getter = getattr(ModelDiscoveryService, "get", None)
return getter() if callable(getter) else ModelDiscoveryService()
except (ImportError, AttributeError, TypeError):
pass
try:
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
getter = getattr(ModelDiscoveryService, "get", None)
return getter() if callable(getter) else ModelDiscoveryService()
except (ImportError, AttributeError, TypeError):
return None
def _normalise(provider: str, raw: Any) -> CachedModels:
if raw is None:
return CachedModels(provider=provider)
if isinstance(raw, dict):
models = raw.get("models") or raw.get("discovered_models") or []
fetched_at = raw.get("fetched_at") or raw.get("discovered_at") or raw.get("updated_at") or raw.get("last_refresh_at") or ""
stale = raw.get("is_stale", raw.get("stale", False))
reason = raw.get("unavailable_reason") or raw.get("error") or ""
else:
models = getattr(raw, "models", None) or getattr(raw, "discovered_models", None) or []
fetched_at = (
getattr(raw, "fetched_at", None)
or getattr(raw, "updated_at", None)
or getattr(raw, "last_refresh_at", None)
or ""
)
stale = getattr(raw, "is_stale", getattr(raw, "stale", False))
reason = getattr(raw, "unavailable_reason", None) or getattr(raw, "error", None) or ""
clean = tuple(dict.fromkeys(str(model).strip() for model in models if str(model).strip()))
return CachedModels(
provider=provider,
models=clean,
fetched_at=str(fetched_at),
is_stale=bool(stale),
unavailable_reason=str(reason or ("" if clean else "Список моделей ещё не получен")),
)
def get_cached_models(provider: str) -> CachedModels:
"""Return cached models immediately; never performs provider I/O."""
service = _service()
if service is None:
return CachedModels(provider=provider, unavailable_reason="Служба обнаружения моделей ещё не подключена")
for name in ("get_cached", "get_cached_models", "get_snapshot", "get_provider_models"):
method = getattr(service, name, None)
if not callable(method):
continue
try:
return _normalise(provider, method(provider))
except Exception as exc:
return CachedModels(provider=provider, unavailable_reason=f"Кэш моделей недоступен: {exc}")
return CachedModels(provider=provider, unavailable_reason="Служба не предоставляет чтение кэша моделей")
def refresh_models_async(provider: str, on_complete: Callable[[CachedModels], None]) -> bool:
"""Request a backend background refresh without blocking the Tk thread."""
service = _service()
if service is None:
on_complete(get_cached_models(provider))
return False
for name in ("refresh_models_async", "refresh_models", "refresh_provider_async", "refresh_async", "discover_async"):
method = getattr(service, name, None)
if not callable(method):
continue
try:
result: Optional[Any] = method(provider, on_complete=lambda *_args: on_complete(get_cached_models(provider)))
return result is not False
except TypeError:
try:
result = method(provider)
return result is not False
except Exception:
break
except Exception:
break
on_complete(get_cached_models(provider))
return False

View file

@ -1,274 +0,0 @@
"""Versioned presentation model for the Team routing graph.
The router YAML remains the source of truth for profiles and role chains. This
module stores only topology/layout metadata next to it and applies profile
assignments through :class:`AutoAssigner`.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import copy
import json
from pathlib import Path
from typing import Any, Iterable, Optional
from uuid import uuid4
from antigravity_provider import paths
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.router_config import RouterConfig, load_router_config
SCHEMA_VERSION = 1
EDGE_TYPES = ("PRIMARY", "FALLBACK", "DELEGATE")
CANONICAL_ROLES = ("orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast")
ROLE_LABELS = {
"orchestrator": "Оркестратор",
"coder-primary": "Основной кодер",
"coder-secondary": "Резервный кодер",
"reviewer": "Ревьюер",
"research": "Исследователь",
"fast": "Быстрый агент",
}
@dataclass
class GraphNode:
role_id: str
x: float
y: float
label: str = ""
@dataclass
class GraphEdge:
source: str
target: str
edge_type: str = "DELEGATE"
profile_id: str = ""
edge_id: str = field(default_factory=lambda: uuid4().hex[:12])
@dataclass
class GraphIssue:
code: str
message: str
node_id: str = ""
edge_id: str = ""
@dataclass
class RoutingGraph:
schema_version: int = SCHEMA_VERSION
nodes: list[GraphNode] = field(default_factory=list)
edges: list[GraphEdge] = field(default_factory=list)
zoom: float = 1.0
viewport_x: float = 0.0
viewport_y: float = 0.0
def clone(self) -> "RoutingGraph":
return copy.deepcopy(self)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "RoutingGraph":
if int(value.get("schema_version", 0)) != SCHEMA_VERSION:
raise ValueError("Unsupported routing graph schema")
return cls(
schema_version=SCHEMA_VERSION,
nodes=[GraphNode(**item) for item in value.get("nodes", [])],
edges=[GraphEdge(**item) for item in value.get("edges", [])],
zoom=float(value.get("zoom", 1.0)),
viewport_x=float(value.get("viewport_x", 0.0)),
viewport_y=float(value.get("viewport_y", 0.0)),
)
def default_graph(config: Optional[RouterConfig] = None) -> RoutingGraph:
"""Migrate current configured roles without touching their chains."""
config = config or load_router_config()
roles = [role for role in CANONICAL_ROLES if role in config.roles]
roles.extend(role for role in config.roles if role not in roles)
nodes: list[GraphNode] = []
for index, role_id in enumerate(roles):
if role_id == "orchestrator":
x, y = 90.0, 240.0
else:
slot = index - (1 if "orchestrator" in roles else 0)
x, y = 390.0 + (slot // 3) * 300.0, 80.0 + (slot % 3) * 160.0
nodes.append(GraphNode(role_id, x, y, ROLE_LABELS.get(role_id, role_id)))
root = "orchestrator" if "orchestrator" in roles else (roles[0] if roles else "")
edges = [GraphEdge(root, role, "DELEGATE") for role in roles if root and role != root]
return RoutingGraph(nodes=nodes, edges=edges)
class RoutingGraphStore:
def __init__(self, path: Optional[Path] = None):
self.path = path or (paths.get_hermes_home() / "routing_graph.json")
def load(self, config: Optional[RouterConfig] = None) -> RoutingGraph:
try:
return RoutingGraph.from_dict(json.loads(self.path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
return default_graph(config)
def save(self, graph: RoutingGraph) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(graph.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.path)
def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -> list[GraphIssue]:
config = config or load_router_config()
issues: list[GraphIssue] = []
node_ids = [node.role_id for node in graph.nodes]
node_set = set(node_ids)
for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}):
issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id))
orch_node = next((n for n in ("manager", "orchestrator") if n in node_set), None)
if not orch_node:
issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора"))
for node in graph.nodes:
policy = config.roles.get(node.role_id)
if policy is None:
issues.append(GraphIssue("missing-role", f"Роль {node.role_id} отсутствует в конфигурации", node.role_id))
elif not policy.preferred_chain:
issues.append(GraphIssue("empty-chain", f"У роли {node.label or node.role_id} нет профилей", node.role_id))
else:
for profile_id in policy.preferred_chain:
if profile_id not in config.profiles:
issues.append(
GraphIssue("missing-profile", f"Профиль {profile_id} не существует", node.role_id)
)
seen_edges: set[tuple[str, str, str, str]] = set()
adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_set}
for edge in graph.edges:
key = (edge.source, edge.target, edge.edge_type, edge.profile_id)
if key in seen_edges:
issues.append(GraphIssue("duplicate-edge", "Дублирующая связь", edge_id=edge.edge_id))
seen_edges.add(key)
if edge.edge_type not in EDGE_TYPES:
issues.append(GraphIssue("edge-type", f"Неизвестный тип {edge.edge_type}", edge_id=edge.edge_id))
if edge.source not in node_set or edge.target not in node_set:
issues.append(GraphIssue("dangling-edge", "Связь ведёт к отсутствующей роли", edge_id=edge.edge_id))
continue
adjacency[edge.source].append(edge.target)
if edge.profile_id and edge.profile_id not in config.profiles:
issues.append(GraphIssue("missing-profile", f"Профиль {edge.profile_id} не существует", edge.target, edge.edge_id))
visited: set[str] = set()
active: set[str] = set()
def visit(role_id: str) -> None:
if role_id in active:
issues.append(GraphIssue("cycle", f"Цикл маршрутизации через {role_id}", role_id))
return
if role_id in visited:
return
visited.add(role_id)
active.add(role_id)
for target in adjacency.get(role_id, []):
visit(target)
active.remove(role_id)
if orch_node:
visit(orch_node)
for role_id in sorted(node_set - visited):
issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id))
return issues
class RoutingGraphController:
"""Undoable graph editor whose assignments go through AutoAssigner."""
def __init__(self, store: Optional[RoutingGraphStore] = None):
self.store = store or RoutingGraphStore()
self.graph = self.store.load()
self._undo: list[RoutingGraph] = []
self._redo: list[RoutingGraph] = []
self.dirty = False
def _checkpoint(self) -> None:
self._undo.append(self.graph.clone())
self._undo = self._undo[-50:]
self._redo.clear()
self.dirty = True
def move_node(self, role_id: str, x: float, y: float) -> None:
node = next((item for item in self.graph.nodes if item.role_id == role_id), None)
if node and (node.x, node.y) != (x, y):
self._checkpoint()
node.x, node.y = x, y
def add_edge(self, source: str, target: str, edge_type: str, profile_id: str = "") -> tuple[bool, str]:
if edge_type not in EDGE_TYPES:
return False, "Неизвестный тип связи"
self._checkpoint()
self.graph.edges.append(GraphEdge(source, target, edge_type, profile_id))
if profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target, edge_type == "PRIMARY")
if not ok:
self.undo()
return False, message
return True, "Связь добавлена"
def delete_edge(self, edge_id: str) -> None:
if any(edge.edge_id == edge_id for edge in self.graph.edges):
self._checkpoint()
self.graph.edges = [edge for edge in self.graph.edges if edge.edge_id != edge_id]
def set_edge_type(
self, edge_id: str, edge_type: str, profile_id: Optional[str] = None
) -> tuple[bool, str]:
edge = next((item for item in self.graph.edges if item.edge_id == edge_id), None)
if edge is None or edge_type not in EDGE_TYPES:
return False, "Связь не найдена"
self._checkpoint()
edge.edge_type = edge_type
if profile_id is not None:
edge.profile_id = profile_id
if edge.profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
ok, message = AutoAssigner.assign_profile_to_role(edge.profile_id, edge.target, edge_type == "PRIMARY")
if not ok:
self.undo()
return ok, message
return True, "Тип связи изменён"
def auto_layout(self) -> None:
self._checkpoint()
root = next((node for node in self.graph.nodes if node.role_id == "orchestrator"), None)
if root:
root.x, root.y = 80.0, 240.0
others = [node for node in self.graph.nodes if node.role_id != "orchestrator"]
for index, node in enumerate(others):
node.x = 390.0 + (index // 4) * 290.0
node.y = 55.0 + (index % 4) * 135.0
def undo(self) -> bool:
if not self._undo:
return False
self._redo.append(self.graph.clone())
self.graph = self._undo.pop()
self.dirty = True
return True
def redo(self) -> bool:
if not self._redo:
return False
self._undo.append(self.graph.clone())
self.graph = self._redo.pop()
self.dirty = True
return True
def save(self) -> list[GraphIssue]:
issues = validate_graph(self.graph)
if not issues:
self.store.save(self.graph)
self.dirty = False
return issues
def role_chain(self, role_id: str) -> Iterable[str]:
policy = load_router_config().roles.get(role_id)
return tuple(policy.preferred_chain if policy else ())

View file

@ -1,78 +0,0 @@
"""Hermes Hub — Native Brand Splash Screen."""
from __future__ import annotations
import tkinter as tk
from typing import Optional
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
class SplashScreen(ctk.CTkToplevel):
"""Brand-compliant Splash Screen shown during initialization."""
def __init__(self, parent: ctk.CTk, width: int = 500, height: int = 300):
super().__init__(parent)
self.overrideredirect(True)
self.configure(fg_color=Theme.BG_WINDOW)
# Center on screen
self.update_idletasks()
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = (sw - width) // 2
y = (sh - height) // 2
self.geometry(f"{width}x{height}+{x}+{y}")
# Container
container = ctk.CTkFrame(
self,
corner_radius=Theme.RADIUS_LG,
border_width=2,
border_color=Theme.BORDER_ACCENT,
fg_color=Theme.BG_WINDOW,
)
container.pack(fill="both", expand=True, padx=2, pady=2)
# Logo
logo_img = AssetManager.get().get_splash_logo(size=(100, 100))
if logo_img:
ctk.CTkLabel(container, image=logo_img, text="").pack(pady=(28, 8))
ctk.CTkLabel(
container,
text="HERMES HUB",
font=Theme.font_title_hero(),
text_color=Theme.TEXT_ACCENT,
).pack(pady=(0, 2))
ctk.CTkLabel(
container,
text="Multi-Agent & Multi-Provider Control Hub",
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
).pack(pady=(0, 16))
# Progress indicator bar
self.progress = ctk.CTkProgressBar(
container,
width=300,
height=4,
corner_radius=2,
fg_color=Theme.SURFACE,
progress_color=Theme.ACCENT,
mode="indeterminate",
)
self.progress.pack(pady=(0, 20))
self.progress.start()
self.update()
def close(self):
try:
self.progress.stop()
self.destroy()
except Exception:
pass

View file

@ -1,379 +0,0 @@
"""Hermes Hub — Centralized Design Tokens and Theme Definition (v3).
Source of Truth: Brand Guidelines & Brandbook
Primary Palette:
PRIMARY: #0F1510 (Obsidian Forest)
DARK: #1A2A1F (Deep Pine)
SECONDARY: #2F4A36 (Moss Slate)
LIGHT: #F7F1E3 (Warm Ivory)
ACCENT: #CDAA64 (Ancient Gold)
"""
from __future__ import annotations
class Theme:
# ── Core Brand Palette ──
PRIMARY = "#0F1510"
DARK = "#1A2A1F"
SECONDARY = "#2F4A36"
LIGHT = "#F7F1E3"
ACCENT = "#CDAA64"
# ── Surfaces & Backgrounds ──
BG_WINDOW = "#0F1510"
BG_SIDEBAR = "#142018"
BG_HEADER = "#16241B"
BG_STATUSBAR = "#121C15"
BG_MODAL_BACKDROP = "#080B08"
SURFACE = "#1D3123"
SURFACE_HOVER = "#274230"
SURFACE_ACTIVE = "#31543D"
SURFACE_SELECTED = "#284432"
SURFACE_MUTED = "#16251A"
# ── Borders ──
BORDER = "#2F4A36"
BORDER_SUBTLE = "#1F3526"
BORDER_ACCENT = "#CDAA64"
BORDER_HOVER = "#40644B"
# ── Typography Colors ──
TEXT_PRIMARY = "#F7F1E3"
TEXT_SECONDARY = "#C5BEAF"
TEXT_MUTED = "#8A9C8E"
TEXT_ON_ACCENT = "#0F1510"
TEXT_ACCENT = "#CDAA64"
TEXT_DISABLED = "#546457"
# ── Accent States ──
ACCENT_HOVER = "#DCBE7D"
ACCENT_PRESSED = "#BA954E"
ACCENT_DIM = "#3D3522"
ACCENT_GLOW = "#CDAA64"
DANGER_SURFACE = "#5A1E1E"
DANGER_SURFACE_HOVER = "#7A2828"
DANGER_BORDER = "#8A3333"
DANGER_TEXT = "#FFD6D6"
# ── Operational / Status Colors ──
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
STATUS_WARNING = "#D97706" # Amber Gold
STATUS_ERROR = "#DC2626" # Ruby Crimson
STATUS_INFO = "#2563EB" # Azure
STATUS_AUTH_REQUIRED = "#D97706"
STATUS_DISABLED = "#5A6B5D"
STATUS_FAILOVER = "#3B82F6"
STATUS_MAIN = "#CDAA64" # Gold Badge
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
# Semantic roles. Brand gold is intentionally not a health colour.
COLOR_POSITIVE = STATUS_HEALTHY
COLOR_CAUTION = STATUS_WARNING
COLOR_NEGATIVE = STATUS_ERROR
COLOR_NEUTRAL = STATUS_DISABLED
COLOR_BRAND = ACCENT
# ── Provider Specific Accent Colors ──
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
PROVIDER_OPENCODE = "#F97316" # OpenCode Orange
PROVIDER_CLAUDE = "#D97706"
PROVIDER_GROK = "#3B82F6"
PROVIDER_GENERIC = "#8B5CF6"
SCHEMES = ("dark", "hybrid", "light")
SCHEME_LABELS = {
"dark": "Тёмная",
"hybrid": "Средняя (гибрид)",
"light": "Светлая (бежевая)",
}
PALETTES = {
"dark": {
"PRIMARY": "#071A17",
"DARK": "#0B211D",
"SECONDARY": "#244B3C",
"LIGHT": "#F4EAD4",
"ACCENT": "#C89A2B",
"BG_WINDOW": "#061916",
"BG_SIDEBAR": "#08221E",
"BG_HEADER": "#071B18",
"BG_STATUSBAR": "#061512",
"BG_MODAL_BACKDROP": "#020B09",
"SURFACE": "#0B2520",
"SURFACE_HOVER": "#12342C",
"SURFACE_ACTIVE": "#194535",
"SURFACE_SELECTED": "#173B2E",
"SURFACE_MUTED": "#091E1A",
"BORDER": "#36513B",
"BORDER_SUBTLE": "#203A2D",
"BORDER_ACCENT": "#B78525",
"BORDER_HOVER": "#537456",
"TEXT_PRIMARY": "#F8F0DC",
"TEXT_SECONDARY": "#D1C7AE",
"TEXT_MUTED": "#8FA395",
"TEXT_ON_ACCENT": "#102018",
"TEXT_ACCENT": "#E0B84E",
"TEXT_DISABLED": "#617367",
"ACCENT_HOVER": "#E0B84E",
"ACCENT_PRESSED": "#A9781E",
"ACCENT_DIM": "#3D3218",
"ACCENT_GLOW": "#D9AA37",
"DANGER_SURFACE": "#4A2020",
"DANGER_SURFACE_HOVER": "#692A2A",
"DANGER_BORDER": "#8A3B36",
"DANGER_TEXT": "#FFD8D2",
"STATUS_HEALTHY": "#72C943",
"STATUS_WARNING": "#E1A62B",
"STATUS_ERROR": "#E45C4F",
"STATUS_INFO": "#4C8DD8",
"STATUS_AUTH_REQUIRED": "#E1A62B",
"STATUS_DISABLED": "#708078",
"STATUS_FAILOVER": "#4C8DD8",
"STATUS_MAIN": "#C89A2B",
"STATUS_ORCHESTRATOR": "#E0B84E",
"PROVIDER_ANTIGRAVITY": "#74A9FF",
"PROVIDER_CODEX": "#46BE8A",
"PROVIDER_OPENCODE": "#F39A50",
"PROVIDER_CLAUDE": "#DF9C63",
"PROVIDER_GROK": "#6DA6F2",
"PROVIDER_GENERIC": "#A99DD8",
"SIDEBAR_TEXT": "#F8F0DC",
"SIDEBAR_MUTED": "#8FA395",
"SIDEBAR_HOVER": "#12342C",
"SIDEBAR_SELECTED": "#173B2E",
},
"hybrid": {
"PRIMARY": "#F7F2E8",
"DARK": "#0A2721",
"SECONDARY": "#DCE6D7",
"LIGHT": "#FCF8F0",
"ACCENT": "#B98118",
"BG_WINDOW": "#F5F0E7",
"BG_SIDEBAR": "#08251F",
"BG_HEADER": "#FBF8F1",
"BG_STATUSBAR": "#EFE8DB",
"BG_MODAL_BACKDROP": "#E8E0D3",
"SURFACE": "#FBF8F1",
"SURFACE_HOVER": "#EEE8DA",
"SURFACE_ACTIVE": "#E1EAD9",
"SURFACE_SELECTED": "#E4EBD8",
"SURFACE_MUTED": "#F1EBE0",
"BORDER": "#D8CCB8",
"BORDER_SUBTLE": "#E7DDCE",
"BORDER_ACCENT": "#BD8B2F",
"BORDER_HOVER": "#AFA188",
"TEXT_PRIMARY": "#17241D",
"TEXT_SECONDARY": "#4C584F",
"TEXT_MUTED": "#7B817B",
"TEXT_ON_ACCENT": "#FFFFFF",
"TEXT_ACCENT": "#9B6C13",
"TEXT_DISABLED": "#A9AAA4",
"ACCENT_HOVER": "#CF9C39",
"ACCENT_PRESSED": "#936514",
"ACCENT_DIM": "#F0E4C8",
"ACCENT_GLOW": "#C59432",
"DANGER_SURFACE": "#F8E2DE",
"DANGER_SURFACE_HOVER": "#F1CBC5",
"DANGER_BORDER": "#C86155",
"DANGER_TEXT": "#812E28",
"STATUS_HEALTHY": "#397B35",
"STATUS_WARNING": "#BE7B13",
"STATUS_ERROR": "#C6473D",
"STATUS_INFO": "#326DAD",
"STATUS_AUTH_REQUIRED": "#BE7B13",
"STATUS_DISABLED": "#969B94",
"STATUS_FAILOVER": "#326DAD",
"STATUS_MAIN": "#B98118",
"STATUS_ORCHESTRATOR": "#A87817",
"PROVIDER_ANTIGRAVITY": "#326DAD",
"PROVIDER_CODEX": "#247A50",
"PROVIDER_OPENCODE": "#B96324",
"PROVIDER_CLAUDE": "#A55C2C",
"PROVIDER_GROK": "#326DAD",
"PROVIDER_GENERIC": "#7365A5",
"SIDEBAR_TEXT": "#F8F0DC",
"SIDEBAR_MUTED": "#98A89D",
"SIDEBAR_HOVER": "#12342C",
"SIDEBAR_SELECTED": "#173B2E",
},
"light": {
"PRIMARY": "#FFF9EE",
"DARK": "#FFF8EC",
"SECONDARY": "#E9DFC9",
"LIGHT": "#FFFDF8",
"ACCENT": "#A96F12",
"BG_WINDOW": "#FFF9EF",
"BG_SIDEBAR": "#FBF0DE",
"BG_HEADER": "#FFFDF8",
"BG_STATUSBAR": "#F7EDDE",
"BG_MODAL_BACKDROP": "#EDE2D2",
"SURFACE": "#FFFDF8",
"SURFACE_HOVER": "#F4E9D9",
"SURFACE_ACTIVE": "#DFE8D7",
"SURFACE_SELECTED": "#E7EEDC",
"SURFACE_MUTED": "#F8F0E5",
"BORDER": "#DDCFB9",
"BORDER_SUBTLE": "#EDE2D2",
"BORDER_ACCENT": "#B57A1C",
"BORDER_HOVER": "#B8A78D",
"TEXT_PRIMARY": "#1E281F",
"TEXT_SECONDARY": "#505A51",
"TEXT_MUTED": "#7E837C",
"TEXT_ON_ACCENT": "#FFFFFF",
"TEXT_ACCENT": "#90600F",
"TEXT_DISABLED": "#ADAEA7",
"ACCENT_HOVER": "#C58B2D",
"ACCENT_PRESSED": "#835609",
"ACCENT_DIM": "#F2E3C4",
"ACCENT_GLOW": "#BA8428",
"DANGER_SURFACE": "#F9E5E0",
"DANGER_SURFACE_HOVER": "#F3D0C9",
"DANGER_BORDER": "#C85A4E",
"DANGER_TEXT": "#812E28",
"STATUS_HEALTHY": "#367B36",
"STATUS_WARNING": "#B97612",
"STATUS_ERROR": "#C6473D",
"STATUS_INFO": "#326DAD",
"STATUS_AUTH_REQUIRED": "#B97612",
"STATUS_DISABLED": "#999D96",
"STATUS_FAILOVER": "#326DAD",
"STATUS_MAIN": "#A96F12",
"STATUS_ORCHESTRATOR": "#9D6810",
"PROVIDER_ANTIGRAVITY": "#326DAD",
"PROVIDER_CODEX": "#247A50",
"PROVIDER_OPENCODE": "#B96324",
"PROVIDER_CLAUDE": "#A55C2C",
"PROVIDER_GROK": "#326DAD",
"PROVIDER_GENERIC": "#7365A5",
"SIDEBAR_TEXT": "#283129",
"SIDEBAR_MUTED": "#767D76",
"SIDEBAR_HOVER": "#F0E3D0",
"SIDEBAR_SELECTED": "#E6EEDB",
},
}
current_scheme = "dark"
@classmethod
def apply_scheme(cls, scheme: str) -> str:
"""Activate a complete palette and return its normalized key."""
normalized = scheme if scheme in cls.PALETTES else "dark"
for token, value in cls.PALETTES[normalized].items():
setattr(cls, token, value)
cls.current_scheme = normalized
cls.COLOR_POSITIVE = cls.STATUS_HEALTHY
cls.COLOR_CAUTION = cls.STATUS_WARNING
cls.COLOR_NEGATIVE = cls.STATUS_ERROR
cls.COLOR_NEUTRAL = cls.STATUS_DISABLED
cls.COLOR_BRAND = cls.ACCENT
return normalized
# ── Spacing Scale (px) ──
SPACE_XS = 4
SPACE_SM = 8
SPACE_MD = 12
SPACE_LG = 16
SPACE_XL = 24
SPACE_2XL = 32
# Layout aliases used by views and reusable components.
PAGE_PAD_X = SPACE_LG
PAGE_PAD_Y = SPACE_MD
SECTION_GAP = SPACE_MD
CARD_PAD_X = SPACE_MD
CARD_PAD_Y = SPACE_MD
INLINE_GAP = SPACE_SM
# ── Corner Radius Scale (px) ──
RADIUS_SM = 6
RADIUS_MD = 10
RADIUS_LG = 14
RADIUS_PILL = 999
# ── Control Heights & Dimensions ──
HEIGHT_BTN_SM = 30
HEIGHT_BTN_MD = 38
HEIGHT_BTN_LG = 44
HEIGHT_NAV_ITEM = 34
HEIGHT_HEADER = 56
HEIGHT_STATUSBAR = 30
WIDTH_SIDEBAR = 190
HEIGHT_INPUT = 36
HEIGHT_BADGE = 24
ACCOUNT_CARD_MIN_HEIGHT = 188
ACCOUNT_CARD_COLUMNS = 3
# ── Fonts ──
FONT_FAMILY_TITLE = "Cinzel"
FONT_FAMILY_UI = "Segoe UI"
FONT_FAMILY_MONO = "Consolas"
@classmethod
def font_title_hero(cls):
return (cls.FONT_FAMILY_TITLE, 24, "bold")
@classmethod
def font_title_page(cls):
return (cls.FONT_FAMILY_UI, 20, "bold")
@classmethod
def font_title(cls):
"""Compatibility alias for the normalized page title."""
return cls.font_title_page()
@classmethod
def font_title_section(cls):
return (cls.FONT_FAMILY_UI, 17, "bold")
@classmethod
def font_heading(cls):
return (cls.FONT_FAMILY_UI, 15, "bold")
@classmethod
def font_subheading(cls):
return (cls.FONT_FAMILY_UI, 14, "bold")
@classmethod
def font_body(cls):
return (cls.FONT_FAMILY_UI, 13)
@classmethod
def font_body_bold(cls):
return (cls.FONT_FAMILY_UI, 13, "bold")
@classmethod
def font_caption(cls):
return (cls.FONT_FAMILY_UI, 11)
@classmethod
def font_micro(cls):
return (cls.FONT_FAMILY_UI, 10)
@classmethod
def font_mono(cls):
return (cls.FONT_FAMILY_MONO, 13)
@classmethod
def font_mono_sm(cls):
return (cls.FONT_FAMILY_MONO, 12)
@classmethod
def font_micro_bold(cls):
return (cls.FONT_FAMILY_UI, 10, "bold")
@classmethod
def font_badge_bold(cls):
return (cls.FONT_FAMILY_UI, 10, "bold")
@classmethod
def font_icon(cls):
return (cls.FONT_FAMILY_UI, 15)
@classmethod
def font_metric(cls):
return (cls.FONT_FAMILY_UI, 24, "bold")
Theme.apply_scheme("dark")

View file

@ -1,3 +0,0 @@
"""Hermes Hub UI Views Package."""
from __future__ import annotations

View file

@ -1,110 +0,0 @@
"""Hermes Hub — About View (О программе)."""
from __future__ import annotations
from typing import Any, Optional
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import (
HubButton,
HubCard,
HubSectionHeader,
)
from antigravity_provider.version import __version__
class AboutView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._build()
def _build(self):
header = HubSectionHeader(
self,
title="О программе",
subtitle="Информация о версии, архитектуре и назначении Hermes Hub",
)
header.pack(fill="x", padx=20, pady=(16, 12))
card = HubCard(self, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
card.pack(fill="both", expand=True, padx=20, pady=(0, 15))
# Center logo
logo_img = AssetManager.get().get_logo_image(size=(140, 140))
if logo_img:
logo_lbl = ctk.CTkLabel(card, image=logo_img, text="")
logo_lbl.pack(pady=(24, 10))
ctk.CTkLabel(
card,
text="HERMES HUB",
font=Theme.font_title_hero(),
text_color=Theme.TEXT_ACCENT,
).pack(pady=(0, 4))
ctk.CTkLabel(
card,
text="Multi-Agent & Multi-Provider Control Hub",
font=Theme.font_subheading(),
text_color=Theme.TEXT_PRIMARY,
).pack(pady=(0, 2))
ctk.CTkLabel(
card,
text=f"Версия {__version__} • Native Windows Application • Local-First",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
).pack(pady=(0, 16))
# Info Box
info_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
info_box.pack(fill="x", padx=32, pady=(0, 16))
desc_text = (
"Hermes Hub — нативный центр управления мультиагентной системой Hermes, "
"аккаунтами AI-провайдеров, моделями и отказоустойчивой маршрутизацией."
)
ctk.CTkLabel(
info_box,
text=desc_text,
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
wraplength=640,
justify="center",
).pack(padx=16, pady=14)
# Pillars
pillars_box = ctk.CTkFrame(card, fg_color="transparent")
pillars_box.pack(fill="x", padx=32, pady=(0, 16))
for i in range(3):
pillars_box.grid_columnconfigure(i, weight=1)
p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p1.grid(row=0, column=0, padx=4, sticky="nsew")
ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(8, 2)
)
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
pady=(0, 8)
)
p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p2.grid(row=0, column=1, padx=4, sticky="nsew")
ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(8, 2)
)
ctk.CTkLabel(
p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 8))
p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p3.grid(row=0, column=2, padx=4, sticky="nsew")
ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(8, 2)
)
ctk.CTkLabel(
p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 8))

View file

@ -1,266 +0,0 @@
"""Accounts and quota view driven exclusively by a supplied HubSnapshot."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Callable, Dict, Iterable, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import (
AccountCardWidget,
ActionButton,
EmptyState,
FilterButton,
SearchField,
SectionHeader,
)
from antigravity_provider.router.ui.theme import Theme
PROVIDER_LABELS = {
"antigravity": "Google Antigravity",
"openai-codex": "OpenAI Codex",
"opencode-go": "OpenCode Go",
"claude": "Claude",
"grok": "Grok",
}
class ProviderGroup(ctk.CTkFrame):
"""Fixed provider section with always-visible Cockpit-style cards."""
def __init__(self, master: Any, provider: str):
super().__init__(master, fg_color="transparent")
self.provider = provider
self.collapsed = False
self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM)
self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS))
self.title = ctk.CTkLabel(
self.header,
text=PROVIDER_LABELS.get(provider, provider),
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
)
self.title.pack(side="left", padx=Theme.SPACE_MD, pady=Theme.SPACE_SM)
self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.count.pack(side="right", padx=Theme.SPACE_MD)
self.body = ctk.CTkFrame(self, fg_color="transparent")
self.body.pack(fill="x")
for column in range(Theme.ACCOUNT_CARD_COLUMNS):
self.body.grid_columnconfigure(column, weight=1)
def toggle_collapsed(self) -> None:
self.collapsed = False
self.body.pack(fill="x")
class AccountsView(ctk.CTkFrame):
"""Keyed accounts view; one account delta never reconstructs another card."""
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action
self._snapshot: Optional[HubSnapshot] = None
self._cards: Dict[str, AccountCardWidget] = {}
self._groups: Dict[str, ProviderGroup] = {}
self._search = ""
self._provider_filter = "Все провайдеры"
self._health_filter = "Все состояния"
self._role_filter = "Все роли"
self.cards_created = 0
self.cards_destroyed = 0
self._build()
def _build(self) -> None:
header = SectionHeader(
self,
title="Аккаунты и квоты",
subtitle="Компактные карточки аккаунтов, реальные остатки и быстрые действия",
action_text="+ Добавить аккаунт",
action_cmd=lambda: self._emit("add_account", {}),
)
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
self.search = SearchField(toolbar, placeholder_text="Поиск по аккаунту, модели или роли…", width=300)
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
self.search.bind("<KeyRelease>", lambda _event: self._set_search(self.search.get()))
self.provider_filter = FilterButton(
toolbar,
values=["Все провайдеры", *PROVIDER_LABELS.values()],
command=self._set_provider_filter,
width=170,
)
self.provider_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
self.health_filter = FilterButton(
toolbar,
values=["Все состояния", "Работают", "Требуют входа", "Проблемные"],
command=self._set_health_filter,
width=150,
)
self.health_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
self.role_filter = FilterButton(
toolbar,
values=["Все роли", "orchestrator", "coder", "reviewer", "researcher", "tester", "general", "spare"],
command=self._set_role_filter,
width=135,
)
self.role_filter.pack(side="left")
ActionButton(
toolbar,
text="Обновить все",
variant="secondary",
command=lambda: self._emit("refresh_all", {}),
).pack(side="right")
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.empty = EmptyState(
self.scroll,
title="Нет подходящих аккаунтов",
message="Измените фильтры или подключите новый аккаунт.",
action_text="Подключить аккаунт",
action_cmd=lambda: self._emit("add_account", {}),
)
def _emit(self, action: str, profile: Any) -> None:
if not self.on_action:
return
if isinstance(profile, dict):
payload = profile
else:
payload = {
"profile_id": profile.profile_id,
"provider": profile.provider,
"display_name": profile.display_name,
}
self.on_action(action, payload)
def _set_search(self, value: str) -> None:
self._search = value.strip().lower()
self._render_visibility()
def _set_provider_filter(self, value: str) -> None:
self._provider_filter = value
self._render_visibility()
def _set_health_filter(self, value: str) -> None:
self._health_filter = value
self._render_visibility()
def _set_role_filter(self, value: str) -> None:
self._role_filter = value
self._render_visibility()
def _matches(self, profile: Any) -> bool:
if self._provider_filter != "Все провайдеры":
if PROVIDER_LABELS.get(profile.provider, profile.provider) != self._provider_filter:
return False
if self._health_filter == "Работают" and profile.health_state != "healthy":
return False
if self._health_filter == "Требуют входа" and profile.auth_state not in {"AUTH_REQUIRED", "AUTH_EXPIRED"}:
return False
if self._health_filter == "Проблемные" and profile.health_state in {"healthy", "not_configured", "cold_spare"}:
return False
roles = list(getattr(profile, "assigned_roles", []) or [])
if self._role_filter != "Все роли" and not any(self._role_filter.lower() in role.lower() for role in roles):
return False
haystack = " ".join(
[
AccountCardWidget.resolve_identity(profile),
profile.display_name,
profile.provider_display_name,
*roles,
*(getattr(profile, "preferred_models", []) or []),
]
).lower()
return not self._search or self._search in haystack
def _profiles(self) -> Iterable[Any]:
if not self._snapshot:
return []
return (profile for profile in self._snapshot.all_profiles.values() if not profile.is_empty_slot)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self._snapshot = snapshot
live_ids = {profile.profile_id for profile in self._profiles()}
for profile_id in list(self._cards):
if profile_id not in live_ids:
self._cards.pop(profile_id).destroy()
self.cards_destroyed += 1
for profile in self._profiles():
group = self._groups.get(profile.provider)
if group is None:
group = ProviderGroup(self.scroll, profile.provider)
self._groups[profile.provider] = group
card = self._cards.get(profile.profile_id)
if card is None:
card = AccountCardWidget(
group.body,
profile.profile_id,
AccountCardWidget.resolve_identity(profile),
profile.provider_display_name,
compact=True,
on_action=self._emit,
)
self._cards[profile.profile_id] = card
self.cards_created += 1
card.update_account(profile, snapshot.quotas.get(profile.profile_id))
self._render_visibility()
def _render_visibility(self) -> None:
if not self._snapshot:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
return
grouped: Dict[str, list[Any]] = defaultdict(list)
for profile in self._profiles():
if self._matches(profile):
grouped[profile.provider].append(profile)
visible_total = 0
for provider, group in self._groups.items():
profiles = grouped.get(provider, [])
if not profiles:
group.pack_forget()
continue
group.pack(fill="x")
group.count.configure(text=f"{len(profiles)} аккаунт(а)")
for card in self._cards.values():
if card.profile_model and card.profile_model.provider == provider:
card.grid_remove()
for index, profile in enumerate(profiles):
self._cards[profile.profile_id].grid(
row=index // Theme.ACCOUNT_CARD_COLUMNS,
column=index % Theme.ACCOUNT_CARD_COLUMNS,
padx=Theme.SPACE_XS,
pady=Theme.SPACE_XS,
sticky="nsew",
)
visible_total += len(profiles)
if visible_total:
self.empty.pack_forget()
else:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
def render_stats(self) -> Dict[str, int]:
return {
"cards_created": self.cards_created,
"cards_destroyed": self.cards_destroyed,
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
}
def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool:
"""Keep account action feedback next to the originating controls."""
card = self._cards.get(profile_id)
if card is None:
return False
card.set_action_feedback(message, success)
return True

View file

@ -1,75 +0,0 @@
"""Empirical router telemetry and local host measurements."""
from __future__ import annotations
from typing import Any, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubMetricCard, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class AnalyticsView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
SectionHeader(
self,
title="Аналитика",
subtitle="Только собственные измерения Hermes Router",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
grid = ctk.CTkFrame(self, fg_color="transparent")
grid.pack(fill="x", padx=Theme.PAGE_PAD_X)
for column in range(3):
grid.grid_columnconfigure(column, weight=1)
definitions = (
("calls", "Вызовы", ""),
("latency", "P50 задержка", ""),
("errors", "Доля ошибок", ""),
("tokens", "Токены", ""),
("failovers", "Переключения", ""),
("cost", "Стоимость", "$"),
)
self.cards = {}
for index, (key, title, icon) in enumerate(definitions):
card = HubMetricCard(grid, title, "Н", "нет измерений", icon=icon)
card.grid(row=index // 3, column=index % 3, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
self.cards[key] = card
self.note = ctk.CTkLabel(
self,
text="Вызовы измерены Hermes Router; аппаратные показатели — локально через psutil. Внешний SLA не заявляется.",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
self.note.pack(anchor="w", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_LG)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
telemetry = dict(snapshot.metrics.get("telemetry") or {})
global_telemetry = dict(telemetry.get("global") or {})
if not telemetry.get("has_data"):
for card in self.cards.values():
card.val_label.configure(text="Н")
card.sub_label.configure(text="нет измерений")
return
values = {
"calls": str(global_telemetry.get("total_calls", "Н")),
"latency": f"{global_telemetry['latency_p50_ms']:.0f} мс"
if global_telemetry.get("latency_p50_ms") is not None
else "Н",
"errors": f"{global_telemetry['error_rate']:.1%}"
if global_telemetry.get("error_rate") is not None
else "Н",
"tokens": str(global_telemetry.get("total_tokens"))
if global_telemetry.get("total_tokens") is not None
else "Н",
"failovers": str(global_telemetry.get("failovers_count", 0)),
"cost": f"${global_telemetry['total_cost_usd']:.4f}"
if global_telemetry.get("total_cost_usd") is not None
else "Н",
}
for key, value in values.items():
self.cards[key].val_label.configure(text=value)
self.cards[key].sub_label.configure(text="own_measurement")

View file

@ -1,746 +0,0 @@
"""Approved Hermes Hub overview, visually aligned with the B5 reference."""
from __future__ import annotations
import tkinter as tk
from typing import Any, Callable, Dict, Iterable, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import HubButton, HubCard
from antigravity_provider.router.ui.theme import Theme
class _Sparkline(ctk.CTkFrame):
"""Tiny chart built only from values observed during this UI session."""
def __init__(self, master: Any, width: int = 58, height: int = 18):
super().__init__(master, width=width, height=height, fg_color="transparent")
self.pack_propagate(False)
self._width, self._height = width, height
self._values: list[float] = []
self.canvas = tk.Canvas(self, width=width, height=height, bg=Theme.SURFACE, highlightthickness=0, bd=0)
self.canvas.pack(fill="both", expand=True)
def update_value(self, value: Optional[float]) -> None:
if value is not None:
self._values.append(float(value))
self._values = self._values[-18:]
self.canvas.delete("all")
if not self._values:
self.canvas.create_line(3, self._height - 4, self._width - 3, self._height - 4, fill=Theme.BORDER_SUBTLE)
return
values = self._values if len(self._values) > 1 else [self._values[0], self._values[0]]
low, high = min(values), max(values)
span = max(high - low, max(abs(high), 1.0) * 0.08)
points: list[float] = []
for index, current in enumerate(values):
points.extend(
(
3 + index * (self._width - 6) / max(len(values) - 1, 1),
self._height - 3 - ((current - low) / span) * (self._height - 7),
)
)
self.canvas.create_line(*points, fill=Theme.STATUS_HEALTHY, width=1.4, smooth=True)
class _KpiCard(HubCard):
def __init__(self, master: Any, title: str, value: str, subtext: str):
super().__init__(master, corner_radius=Theme.RADIUS_SM, height=62)
self.grid_propagate(False)
self.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(self, text=title, font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY).grid(
row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(7, 0)
)
self.val_label = ctk.CTkLabel(self, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.val_label.grid(row=1, column=0, sticky="w", padx=10, pady=(0, 6))
self.sub_label = ctk.CTkLabel(self, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.sub_label.grid(row=1, column=1, sticky="e", padx=(2, 5), pady=(0, 6))
self.spark = _Sparkline(self, width=48, height=16)
self.spark.grid(row=1, column=2, sticky="e", padx=(0, 7), pady=(0, 5))
def set_metric(self, value: str, subtext: str, numeric: Optional[float] = None) -> None:
self.val_label.configure(text=value)
self.sub_label.configure(text=subtext)
self.spark.update_value(numeric)
class _EndpointCard(HubCard):
"""Compact provider/agent card with brand icon and a real quota bar."""
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_MD, height=76)
self.pack_propagate(False)
self.icon = ctk.CTkLabel(self, text="", width=38, font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT)
self.icon.pack(side="left", padx=(8, 5))
text = ctk.CTkFrame(self, fg_color="transparent")
text.pack(side="left", fill="both", expand=True, pady=3)
self.title = ctk.CTkLabel(text, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w")
self.subtitle = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack(anchor="w", pady=(0, 1))
self.status = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY)
self.status.pack(anchor="w")
quota = ctk.CTkFrame(self, fg_color="transparent", width=92)
quota.pack(side="right", fill="y", padx=(3, 8), pady=8)
quota.pack_propagate(False)
self.quota_label = ctk.CTkLabel(quota, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.quota_label.pack(anchor="e")
self.progress = ctk.CTkProgressBar(
quota, height=4, corner_radius=2, progress_color=Theme.STATUS_HEALTHY, fg_color=Theme.SURFACE_MUTED
)
self.progress.pack(fill="x", pady=(5, 0))
self.progress.set(0)
self._click_action: Optional[Callable[[], None]] = None
self._status = "unknown"
self._bind_click_tree(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._activate, add="+")
self.bind("<space>", self._activate, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_click_tree(self, widget: Any) -> None:
widget.configure(cursor="hand2")
widget.bind("<Button-1>", self._activate, add="+")
for child in widget.winfo_children():
self._bind_click_tree(child)
def set_click_action(self, action: Optional[Callable[[], None]]) -> None:
self._click_action = action
self.configure(cursor="hand2" if action else "arrow")
def _activate(self, _event: Any = None) -> None:
if self._click_action:
self._click_action()
def _hover_on(self, _event: Any = None) -> None:
if self._click_action:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
self.configure(
fg_color=Theme.SURFACE,
border_color=Theme.BORDER_ACCENT if self._status == "healthy" else Theme.BORDER,
)
def update_card(
self,
provider_key: str,
title: str,
subtitle: str,
status_text: str,
quota_text: str,
quota_percent: Optional[float],
status: str,
) -> None:
self._status = status
image = AssetManager.get().get_provider_image(provider_key, size=(30, 30))
self.icon.configure(image=image, text="" if image else "")
self.icon.image = image
self.title.configure(text=title)
self.subtitle.configure(text=subtitle)
self.status.configure(text=f"{status_text}")
self.quota_label.configure(text=quota_text)
color = {
"healthy": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(status, Theme.STATUS_DISABLED)
self.status.configure(text_color=color)
self.configure(border_color=Theme.BORDER_ACCENT if status == "healthy" else Theme.BORDER)
self.progress.configure(progress_color=color)
self.progress.set(max(0.0, min(1.0, quota_percent / 100.0)) if quota_percent is not None else 0)
class _OrchestratorNode(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, width=174, height=150, fg_color="transparent")
self.pack_propagate(False)
self.circle = ctk.CTkFrame(
self,
width=96,
height=96,
corner_radius=48,
fg_color=Theme.BG_SIDEBAR if Theme.current_scheme != "light" else Theme.SURFACE,
border_width=2,
border_color=Theme.BORDER_ACCENT,
)
self.circle.pack(pady=(0, 3))
self.circle.pack_propagate(False)
logo = AssetManager.get().get_logo_image(size=(76, 76))
self.logo = ctk.CTkLabel(
self.circle, image=logo, text="H" if logo is None else "", text_color=Theme.TEXT_ACCENT
)
self.logo.image = logo
self.logo.place(relx=0.5, rely=0.5, anchor="center")
ctk.CTkLabel(
self, text="Главный оркестратор", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack()
self.subtitle = ctk.CTkLabel(self, text="Не назначен", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack()
self.status = ctk.CTkLabel(self, text="Н", font=Theme.font_micro(), text_color=Theme.STATUS_WARNING)
self.status.pack()
def update_node(self, subtitle: str, status_text: str, active: bool) -> None:
self.subtitle.configure(text=subtitle)
self.status.configure(
text=f"{status_text}", text_color=Theme.STATUS_HEALTHY if active else Theme.STATUS_WARNING
)
class _RouteDiagram(ctk.CTkFrame):
"""Responsive diagram with smooth connections behind native widgets."""
def __init__(self, master: Any):
super().__init__(master, height=440, fg_color="transparent")
self.pack_propagate(False)
self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1)
self.provider_slots: list[Any] = []
self.agent_slots: list[Any] = []
self.orchestrator = _OrchestratorNode(self)
self.orchestrator.place(relx=0.51, rely=0.46, anchor="center")
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
self.context.place(relx=0.51, rely=0.91, relwidth=0.25, anchor="center")
ctk.CTkLabel(
self.context, text="▤ Хранилище контекста", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
).pack(pady=(6, 0))
self.context_status = ctk.CTkLabel(
self.context,
text="● Нет телеметрии хранилища",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
)
self.context_status.pack()
self._left_labels: list[str] = []
self._right_labels: list[str] = []
self._redraw_timer_id: Any = None
self.bind("<Configure>", self._schedule_redraw)
def _schedule_redraw(self, event: Any = None) -> None:
"""Свести поток <Configure> к одной перерисовке.
При перетаскивании окна Tk шлёт <Configure> непрерывно, и раньше каждое
событие вызывало полную перерисовку канвы: удаление и построение всех
связей, подписей и узлов. Очередь не успевала разгребаться, и окно
продолжало ползти ещё несколько секунд после того, как мышь отпущена.
Перерисовываем один раз, когда поток событий утих.
"""
if self._redraw_timer_id is not None:
try:
self.after_cancel(self._redraw_timer_id)
except Exception:
pass
self._redraw_timer_id = self.after(80, self._redraw_now)
def _redraw_now(self) -> None:
self._redraw_timer_id = None
try:
self._redraw()
except Exception:
pass
def sync_slots(self, provider_count: int, agent_count: int) -> None:
"""Grow/shrink endpoint slots to match the snapshot without hard caps."""
while len(self.provider_slots) < provider_count:
self.provider_slots.append(ctk.CTkFrame(self, height=76, fg_color="transparent"))
while len(self.agent_slots) < agent_count:
self.agent_slots.append(ctk.CTkFrame(self, height=68, fg_color="transparent"))
for index, slot in enumerate(self.provider_slots):
if index >= provider_count:
slot.place_forget()
continue
y = 0.02 + index * (0.78 / max(provider_count - 1, 1))
slot.place(relx=0.012, rely=y, relwidth=0.30)
for index, slot in enumerate(self.agent_slots):
if index >= agent_count:
slot.place_forget()
continue
y = 0.01 + index * (0.80 / max(agent_count - 1, 1))
slot.place(relx=0.71, rely=y, relwidth=0.278)
self._redraw()
def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = list(left)
self._right_labels = list(right)
self._redraw()
def _redraw(self, _event: Any = None) -> None:
width, height = max(self.winfo_width(), 600), max(self.winfo_height(), 300)
self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71
left_count = len(self._left_labels)
left_ys = [
height * (0.02 + index * (0.78 / max(left_count - 1, 1))) + 38 for index in range(left_count)
]
for index, y_pos in enumerate(left_ys):
self.canvas.create_line(
left_x,
y_pos,
left_x + 44,
y_pos,
center_x - 78,
center_y,
center_x - 48,
center_y,
fill=Theme.BORDER_ACCENT,
width=1.35,
smooth=True,
arrow=tk.LAST,
tags="route",
)
self.canvas.create_text(
left_x + 50,
y_pos - 8,
text=self._left_labels[index],
fill=Theme.TEXT_SECONDARY,
font=(Theme.FONT_FAMILY_UI, 8),
anchor="w",
tags="route",
)
right_count = len(self._right_labels)
right_ys = [
height * (0.01 + index * (0.80 / max(right_count - 1, 1))) + 34 for index in range(right_count)
]
for index, y_pos in enumerate(right_ys):
self.canvas.create_line(
center_x + 48,
center_y,
right_x - 34,
y_pos,
right_x,
y_pos,
fill=Theme.BORDER_ACCENT,
width=1.35,
smooth=True,
arrow=tk.LAST,
tags="route",
)
self.canvas.create_text(
right_x - 39,
y_pos - 8,
text=self._right_labels[index],
fill=Theme.TEXT_SECONDARY,
font=(Theme.FONT_FAMILY_UI, 8),
anchor="e",
tags="route",
)
self.canvas.create_line(
center_x,
center_y + 48,
center_x,
height * 0.83,
fill=Theme.BORDER_ACCENT,
width=1.2,
dash=(3, 3),
arrow=tk.LAST,
tags="route",
)
class _StatusRow(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, fg_color="transparent", height=24)
self.pack_propagate(False)
self.dot = ctk.CTkLabel(self, text="", width=12, font=Theme.font_micro())
self.dot.pack(side="left")
self.title = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left", padx=4)
self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.detail.pack(side="right")
def update_row(self, title: str, detail: str, status: str) -> None:
self.title.configure(text=title)
self.detail.configure(text=detail)
self.dot.configure(
text_color={
"healthy": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(status, Theme.STATUS_DISABLED)
)
class _SystemRow(ctk.CTkFrame):
def __init__(self, master: Any, title: str):
super().__init__(master, fg_color="transparent", height=25)
self.pack_propagate(False)
ctk.CTkLabel(
self, text=title, width=47, anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY
).pack(side="left")
self.value = ctk.CTkLabel(
self, text="Н", width=48, anchor="e", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
)
self.value.pack(side="left")
self.spark = _Sparkline(self, width=54, height=16)
self.spark.pack(side="right")
def update_metric(self, text: str, numeric: Optional[float]) -> None:
self.value.configure(text=text)
self.spark.update_value(numeric)
class _EventRow(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, fg_color="transparent", height=23)
self.pack_propagate(False)
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left")
self.dot = ctk.CTkLabel(self, width=18, text="", font=Theme.font_micro())
self.dot.pack(side="left")
self.category = ctk.CTkLabel(
self, width=112, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
)
self.category.pack(side="left", padx=(0, 6))
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.message.pack(side="left", fill="x", expand=True)
self.tag = ctk.CTkLabel(
self,
text="",
width=82,
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL,
)
self.tag.pack(side="right")
def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info"))
color = {
"success": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO)
category = str(getattr(event, "category", "system"))
self.time.configure(text=str(getattr(event, "timestamp", "Н")))
self.dot.configure(text_color=color)
self.category.configure(text=category.replace("_", " ").title())
self.message.configure(text=str(getattr(event, "message", "Событие без описания")))
self.tag.configure(text=category)
class DashboardView(ctk.CTkFrame):
"""Dense overview following the approved dashboard composition."""
def __init__(
self,
master: Any,
app_state: Optional[Dict[str, Any]] = None,
on_navigate: Optional[Callable] = None,
on_action: Optional[Callable] = None,
**kwargs,
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_navigate = on_navigate
self.on_action = on_action
self._provider_cards: Dict[str, _EndpointCard] = {}
self._agent_cards: Dict[str, _EndpointCard] = {}
self._event_rows: list[_EventRow] = []
self._build()
def _build(self) -> None:
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(8, 10))
self.snapshot_freshness = ctk.CTkLabel(
self.scroll,
text="● Система загружается",
font=Theme.font_micro(),
text_color=Theme.STATUS_HEALTHY,
anchor="w",
)
# Kept as a presentation-state probe for tests/accessibility; the same
# status is rendered once in the global header, as in the approved mockup.
self.empty_state = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.ACCENT_DIM)
empty_copy = ctk.CTkFrame(self.empty_state, fg_color="transparent")
empty_copy.pack(side="left", fill="x", expand=True, padx=14, pady=10)
ctk.CTkLabel(
empty_copy,
text="Подключите первый аккаунт",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w")
ctk.CTkLabel(
empty_copy,
text="Hermes назначит профиль роли и покажет реальную квоту, модель и цепочку отказоустойчивости.",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", pady=(2, 0))
HubButton(
self.empty_state,
text="Добавить аккаунт",
variant="primary",
command=lambda: self.on_action("add_account", {}) if self.on_action else None,
).pack(side="right", padx=12, pady=10)
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.metrics.pack(fill="x", pady=(0, 8))
for column in range(6):
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения")
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "активно: 0")
self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы")
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
self.host_metric = _KpiCard(self.metrics, "Нагрузка CPU", "Н", "нет измерения хоста")
for index, metric in enumerate(
(
self.quota_metric,
self.calls_metric,
self.agents_metric,
self.latency_metric,
self.failover_metric,
self.host_metric,
)
):
metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 5 else 3), sticky="nsew")
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x")
body.grid_columnconfigure(0, weight=1)
route_card = HubCard(body, height=480)
route_card.grid(row=0, column=0, sticky="nsew")
route_card.grid_propagate(False)
ctk.CTkLabel(
route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(anchor="w", padx=10, pady=(8, 0))
self.route_diagram = _RouteDiagram(route_card)
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
self.events_card = HubCard(self.scroll)
self.events_card.pack(fill="x", pady=(8, 0))
events_header = ctk.CTkFrame(self.events_card, fg_color="transparent")
events_header.pack(fill="x", padx=10, pady=(7, 3))
ctk.CTkLabel(
events_header, text="Последние события", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(side="left")
ctk.CTkButton(
events_header,
text="Все события →",
width=82,
height=22,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_ACCENT,
font=Theme.font_micro(),
command=lambda: self.on_navigate("logs") if self.on_navigate else None,
).pack(side="right")
self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent")
self.events_body.pack(fill="x", padx=10, pady=(0, 7))
self.events_empty = ctk.CTkLabel(
self.events_body, text="События: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.events_empty.pack(anchor="w", pady=5)
@staticmethod
def _quota_measurement(snapshot: HubSnapshot, provider_id: Optional[str] = None) -> tuple[str, Optional[float]]:
profiles = snapshot.profiles_by_provider.get(provider_id, []) if provider_id else snapshot.all_profiles.values()
for profile in profiles:
quota = snapshot.quotas.get(profile.profile_id)
if not quota or getattr(quota, "is_estimated", True):
continue
for bucket in quota.buckets:
if bucket.remaining_percent is not None:
return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent)
return "Нет данных API", None
@staticmethod
def _agent_quota_measurement(snapshot: HubSnapshot, agent: Any) -> tuple[str, Optional[float]]:
quota = snapshot.quotas.get(agent.assigned_profile_id)
if quota and not getattr(quota, "is_estimated", True):
bucket = quota.get_bucket_for_model(agent.model)
if bucket and bucket.remaining_percent is not None:
remaining = float(bucket.remaining_percent)
return bucket.formatted_remaining(), remaining
reason = getattr(quota, "unavailable_reason", None) if quota else None
return reason or agent.active_quota_label or "Нет телеметрии: роль ещё не вызывалась", None
@staticmethod
def _sync_endpoint_cards(
slots: list[Any],
cache: Dict[str, _EndpointCard],
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
) -> None:
prepared = list(items)[: len(slots)]
live = {key for key, *_rest in prepared}
for key in list(cache):
if key not in live:
cache.pop(key).destroy()
for index, item in enumerate(prepared):
key, provider_key, title, subtitle, status_text, quota_text, quota_percent, status = item
card = cache.get(key)
if card is None:
card = _EndpointCard(slots[index])
card.pack(fill="both", expand=True)
cache[key] = card
card.update_card(provider_key, title, subtitle, status_text, quota_text, quota_percent, status)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self.snapshot_freshness.configure(
text=(
f"⚠ Данные устарели • snapshot #{snapshot.seq}"
if snapshot.is_stale
else f"● Система работает штатно • snapshot #{snapshot.seq}"
),
text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
)
readiness = snapshot.readiness
if readiness.accounts_connected_count == 0:
self.empty_state.pack(fill="x", pady=(0, 8), before=self.metrics)
else:
self.empty_state.pack_forget()
telemetry = dict(snapshot.metrics.get("telemetry") or {})
global_telemetry = dict(telemetry.get("global") or {})
provider_telemetry = dict(telemetry.get("by_provider") or {})
role_telemetry = dict(telemetry.get("by_role") or {})
has_telemetry = bool(telemetry.get("has_data"))
total_calls = global_telemetry.get("total_calls") if has_telemetry else None
latency = global_telemetry.get("latency_p50_ms") if has_telemetry else None
failovers = global_telemetry.get("failovers_count") if has_telemetry else None
active_calls = snapshot.metrics.get("active_calls_total")
host = dict(snapshot.metrics.get("host") or {})
cpu = host.get("cpu_percent") if host.get("has_data") else None
quota_text, quota_percent = self._quota_measurement(snapshot)
self.quota_metric.set_metric(
quota_text, "реальная корзина" if quota_percent is not None else "нет измерения", quota_percent
)
self.calls_metric.set_metric(
str(total_calls) if total_calls is not None else "Н",
f"активно: {active_calls}"
if isinstance(active_calls, int)
else "нет телеметрии: запросы ещё не выполнялись",
float(total_calls) if total_calls is not None else None,
)
self.agents_metric.set_metric(
str(readiness.accounts_connected_count),
f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}",
float(readiness.accounts_connected_count),
)
self.latency_metric.set_metric(
f"{latency:.0f} мс" if latency is not None else "Н",
"P50" if latency is not None else "нет телеметрии: запросы ещё не выполнялись",
float(latency) if latency is not None else None,
)
self.failover_metric.set_metric(
str(failovers) if failovers is not None else "Н",
"failover" if failovers is not None else "нет телеметрии переключений",
float(failovers) if failovers is not None else None,
)
self.host_metric.set_metric(
f"{cpu:.0f}%" if cpu is not None else "Н",
"host_measurement" if cpu is not None else "psutil не вернул измерение",
float(cpu) if cpu is not None else None,
)
providers = list(snapshot.providers)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator]
self.route_diagram.sync_slots(len(providers), len(agents))
self._sync_endpoint_cards(
self.route_diagram.provider_slots,
self._provider_cards,
(
(
provider.provider_id,
provider.provider_id,
provider.provider_name,
(
f"{provider.connected_count} аккаунт(а) • "
+ next(
(
profile.preferred_models[0]
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"модели не обнаружены",
)
),
"Онлайн" if provider.online_count else "Недоступен",
*self._quota_measurement(snapshot, provider.provider_id),
"healthy" if provider.online_count else "warning",
)
for provider in providers
),
)
orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None)
if orchestrator:
self.route_diagram.orchestrator.update_node(
f"{orchestrator.provider_display_name}{orchestrator.model}",
"Онлайн" if orchestrator.is_active else orchestrator.status_label_ru,
orchestrator.is_active,
)
else:
self.route_diagram.orchestrator.update_node("Аккаунт не подключён", "Роль не настроена", False)
agent_items = []
for agent in agents:
agent_quota_text, agent_quota_percent = self._agent_quota_measurement(snapshot, agent)
agent_items.append(
(
agent.role_id,
agent.provider,
agent.role_name_ru,
f"{agent.provider_display_name}{agent.model}",
"Здорово" if agent.is_active else agent.status_label_ru,
agent_quota_text,
agent_quota_percent,
"healthy" if agent.is_active else "warning",
)
)
self._sync_endpoint_cards(
self.route_diagram.agent_slots,
self._agent_cards,
agent_items,
)
for agent in agents:
card = self._agent_cards.get(agent.role_id)
if card is not None:
card.set_click_action(
lambda current=agent: self.on_action(
"agent_settings",
{
"role_id": current.role_id,
"profile_id": current.assigned_profile_id,
"provider": current.provider,
},
)
if self.on_action
else None
)
left_labels: list[str] = []
for provider in providers:
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
left_labels.append(f"{share:.0%}" if share is not None else "нет телеметрии")
right_labels: list[str] = []
for agent in agents:
measured = dict(role_telemetry.get(agent.role_id) or {})
calls = measured.get("total_calls") if measured.get("has_data") else None
right_labels.append(f"{calls} выз." if calls is not None else "нет вызовов")
self.route_diagram.set_labels(left_labels, right_labels)
def update_events(self, events: Iterable[Any]) -> None:
items = list(events)[:5]
while len(self._event_rows) < len(items):
row = _EventRow(self.events_body)
row.pack(fill="x", pady=1)
self._event_rows.append(row)
for index, row in enumerate(self._event_rows):
if index < len(items):
row.update_event(items[index])
row.pack(fill="x", pady=1)
else:
row.pack_forget()
if items:
self.events_empty.pack_forget()
else:
self.events_empty.pack(anchor="w", pady=5)

View file

@ -1,89 +0,0 @@
"""Keyed system-health view using only the supplied snapshot."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubCard, SectionHeader, StatusBadge
from antigravity_provider.router.ui.theme import Theme
class HealthProfileRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.identity.grid(row=0, column=0, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
self.models = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.models.grid(row=0, column=1, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
self.status = StatusBadge(self, "not_tested")
self.status.grid(row=0, column=2, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="e")
self.grid_columnconfigure(0, weight=2)
self.grid_columnconfigure(1, weight=3)
self.grid_columnconfigure(2, weight=1)
def update_profile(self, profile: Any) -> None:
identity = profile.email or profile.account_identity or profile.profile_id
self.identity.configure(text=f"{identity}\n{profile.profile_id}")
families = (
", ".join(f"{family}: {state.status_label_ru}" for family, state in profile.model_states.items())
or "Семейства моделей: Н"
)
self.models.configure(text=families)
self.status.set_status(profile.health_state, profile.health_label_ru)
class HealthView(ctk.CTkFrame):
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_refresh: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._rows: Dict[str, HealthProfileRow] = {}
SectionHeader(
self,
title="Состояние системы",
subtitle="Авторизация, локальное здоровье и наблюдаемые состояния моделей",
action_text="Обновить аудит",
action_cmd=on_refresh,
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.banner = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK)
self.banner.pack(fill="x", pady=(0, Theme.SECTION_GAP))
self.title = ctk.CTkLabel(
self.banner, text="Ожидание snapshot", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT
)
self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.summary = ctk.CTkLabel(self.banner, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY)
self.summary.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
self.rows = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.rows.pack(fill="x")
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
readiness = snapshot.readiness
self.title.configure(text=readiness.title_ru)
warnings = "".join(readiness.warnings) if readiness.warnings else "Предупреждений нет"
self.summary.configure(
text=(
f"{readiness.summary_ru}\n"
f"Аккаунты {readiness.accounts_connected_count}/{readiness.total_accounts}"
f"Роли {readiness.roles_ready_count}/{readiness.total_roles}{warnings}"
)
)
live = set(snapshot.all_profiles)
for profile_id in list(self._rows):
if profile_id not in live:
self._rows.pop(profile_id).destroy()
ordered = sorted(snapshot.all_profiles.values(), key=lambda profile: (profile.provider, profile.display_name))
for index, profile in enumerate(ordered):
row = self._rows.get(profile.profile_id)
if row is None:
row = HealthProfileRow(self.rows)
self._rows[profile.profile_id] = row
row.update_profile(profile)
row.grid(row=index, column=0, sticky="ew", pady=Theme.SPACE_XS)
self.rows.grid_columnconfigure(0, weight=1)

View file

@ -1,127 +0,0 @@
"""Keyed event timeline populated by the application action layer."""
from __future__ import annotations
from typing import Any, Iterable, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import FilterButton, HubCard, SearchField, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class _LogRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
self.time = ctk.CTkLabel(self, text="", width=70, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left", padx=(Theme.CARD_PAD_X, 0), pady=Theme.SPACE_SM)
self.dot = ctk.CTkLabel(self, text="", width=18, font=Theme.font_micro())
self.dot.pack(side="left")
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_SM)
self.category = ctk.CTkLabel(
self,
text="",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL,
)
self.category.pack(side="right", padx=Theme.CARD_PAD_X)
def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info"))
color = {
"success": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO)
self.time.configure(text=str(getattr(event, "timestamp", "Н")))
self.dot.configure(text_color=color)
self.message.configure(text=str(getattr(event, "message", "Н")))
self.category.configure(text=f" {getattr(event, 'category', 'system')} ")
class LogsView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._events: list[Any] = []
self._rows: list[_LogRow] = []
self._query = ""
self._level = "Все уровни"
SectionHeader(
self,
title="Журнал событий",
subtitle="Реальные события Hermes Hub с уровнями и категориями",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
self.search = SearchField(toolbar, placeholder_text="Поиск по журналу…", width=320)
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
self.search.bind("<KeyRelease>", lambda _event: self._set_query(self.search.get()))
self.level = FilterButton(
toolbar,
values=["Все уровни", "Информация", "Успех", "Предупреждение", "Ошибка"],
command=self._set_level,
width=170,
)
self.level.pack(side="left")
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.empty = ctk.CTkLabel(
self.scroll,
text="События: Н",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
def _set_query(self, value: str) -> None:
self._query = value.strip().lower()
self._render()
def _set_level(self, value: str) -> None:
self._level = value
self._render()
def _filtered(self) -> list[Any]:
levels = {
"Информация": "info",
"Успех": "success",
"Предупреждение": "warning",
"Ошибка": "error",
}
expected = levels.get(self._level)
result = []
for event in self._events:
if expected and str(getattr(event, "level", "info")) != expected:
continue
haystack = f"{getattr(event, 'category', '')} {getattr(event, 'message', '')}".lower()
if self._query and self._query not in haystack:
continue
result.append(event)
return result
def _render(self) -> None:
events = self._filtered()
while len(self._rows) < len(events):
row = _LogRow(self.scroll)
self._rows.append(row)
for index, row in enumerate(self._rows):
if index < len(events):
row.update_event(events[index])
row.pack(fill="x", pady=Theme.SPACE_XS)
else:
row.pack_forget()
if events:
self.empty.pack_forget()
else:
self.empty.pack(anchor="w", pady=Theme.SPACE_XL)
def update_events(self, events: Iterable[Any]) -> None:
self._events = list(events)
self._render()
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return

View file

@ -1,96 +0,0 @@
"""Provider summaries updated by stable provider id."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubCard, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class ProviderCard(HubCard):
def __init__(self, master: Any):
super().__init__(master)
self.provider_id = ""
top = ctk.CTkFrame(self, fg_color="transparent")
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
self.icon = ctk.CTkLabel(top, text="", width=24, height=24)
self.icon.pack(side="left", padx=(0, Theme.SPACE_SM))
self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.updated = ctk.CTkLabel(top, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.updated.pack(side="right")
self.stats = ctk.CTkLabel(self, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY)
self.stats.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.models = ctk.CTkLabel(
self,
text="",
font=Theme.font_mono_sm(),
text_color=Theme.TEXT_PRIMARY,
wraplength=900,
justify="left",
)
self.models.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
def update_provider(self, summary: Any) -> None:
self.provider_id = summary.provider_id
from antigravity_provider.router.ui.assets import AssetManager
img = AssetManager.get().get_provider_image(summary.provider_id, size=(24, 24))
self.icon.configure(image=img, text="" if img else "")
self.icon.image = img
self.title.configure(text=summary.provider_name)
self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н/Д — обнаружение ещё не запускалось'}")
self.stats.configure(
text=(
f"Онлайн {summary.online_count}/{summary.connected_count}"
f"требуют входа {summary.auth_required_count}"
f"квота исчерпана {summary.quota_exhausted_count}"
f"холодный резерв {summary.cold_spare_count}"
)
)
self.models.configure(
text="Модели: "
+ (
"".join(summary.discovered_models)
if summary.discovered_models
else "Н/Д — список моделей ещё не получен"
)
)
class ProvidersView(ctk.CTkFrame):
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action
self._cards: Dict[str, ProviderCard] = {}
SectionHeader(
self,
title="Провайдеры и модели",
subtitle="Локальная доступность адаптеров и реально обнаруженные модели",
action_text="Обновить",
action_cmd=lambda: self.on_action and self.on_action("refresh_data", {}),
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
live = {summary.provider_id for summary in snapshot.providers}
for provider_id in list(self._cards):
if provider_id not in live:
self._cards.pop(provider_id).destroy()
for summary in snapshot.providers:
card = self._cards.get(summary.provider_id)
if card is None:
card = ProviderCard(self.scroll)
card.pack(fill="x", pady=Theme.SPACE_XS)
self._cards[summary.provider_id] = card
card.update_provider(summary)

View file

@ -1,83 +0,0 @@
"""Truthful multi-bucket quota overview driven by HubSnapshot."""
from __future__ import annotations
from typing import Any, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import EmptyState, HubCard, QuotaBar, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class _QuotaRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM)
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.title = ctk.CTkLabel(header, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.source = ctk.CTkLabel(header, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.source.pack(side="right")
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.bar = QuotaBar(self, label="Остаток")
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
def update_bucket(self, profile: Any, quota: Any, bucket: Any) -> None:
remaining = getattr(bucket, "remaining_percent", None)
ratio = float(remaining) / 100.0 if remaining is not None else None
detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н"
reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None
estimated = bool(getattr(quota, "is_estimated", True))
self.title.configure(text=f"{bucket.display_name}{profile.provider_display_name}")
self.identity.configure(text=profile.account_identity or profile.display_name)
self.source.configure(text="оценка" if estimated else str(getattr(quota, "source", "измерено")))
self.bar.set_value(ratio, detail)
reason = getattr(quota, "unavailable_reason", None)
self.reset.configure(text=reason or reset or "Сброс: Н")
class QuotasView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._rows: Dict[str, _QuotaRow] = {}
SectionHeader(
self,
title="Квоты и лимиты",
subtitle="Независимые корзины провайдеров; отсутствие измерения не считается нулём",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.empty = EmptyState(
self.scroll,
title="Квоты: Н",
message="Провайдеры ещё не опубликовали ни одной квотной корзины.",
)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
incoming: list[tuple[str, Any, Any, Any]] = []
for profile in snapshot.all_profiles.values():
quota = snapshot.quotas.get(profile.profile_id)
for bucket in list(getattr(quota, "buckets", None) or []):
incoming.append((f"{profile.profile_id}:{bucket.id}", profile, quota, bucket))
live = {key for key, *_rest in incoming}
for key in list(self._rows):
if key not in live:
self._rows.pop(key).destroy()
for key, profile, quota, bucket in incoming:
row = self._rows.get(key)
if row is None:
row = _QuotaRow(self.scroll)
row.pack(fill="x", pady=Theme.SPACE_XS)
self._rows[key] = row
row.update_bucket(profile, quota, bucket)
if incoming:
self.empty.pack_forget()
else:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)

View file

@ -1,120 +0,0 @@
"""Keyed failover-chain view driven exclusively by RolePipeline objects."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import tkinter as tk
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import ActionButton, HubCard, RouteTargetWidget, SectionHeader
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.unified_health import RolePipeline
class RoutingRoleWidget(HubCard):
def __init__(self, master: Any, pipeline: RolePipeline, on_action: Optional[Callable] = None, **kwargs):
super().__init__(master, **kwargs)
self.pipeline = pipeline
self.on_action = on_action
self._nodes: Dict[str, RouteTargetWidget] = {}
top = ctk.CTkFrame(self, fg_color="transparent")
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.meta = ctk.CTkLabel(top, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.meta.pack(side="left", padx=Theme.SPACE_SM)
ActionButton(
top,
text="Изменить цепочку →",
variant="secondary",
width=90,
command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}),
).pack(side="right")
self.chain = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.chain.pack(fill="x", padx=Theme.CARD_PAD_X)
self.footer = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.footer.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
self.update_from_pipeline(pipeline)
def update_from_pipeline(self, pipeline: RolePipeline) -> None:
self.pipeline = pipeline
self.title.configure(text=pipeline.role_name_ru)
affinity = "session affinity" if pipeline.session_affinity else "без affinity"
self.meta.configure(text=f"{pipeline.default_model}{affinity}")
live = {node.profile_id for node in pipeline.nodes}
for profile_id in list(self._nodes):
if profile_id not in live:
self._nodes.pop(profile_id).destroy()
for index, node in enumerate(pipeline.nodes):
rank = "Основной" if index == 0 else f"Резерв {index}"
identity = f"{node.account_identity}" if node.account_identity else ""
subtitle = f"{node.provider}{node.model}{identity}"
widget = self._nodes.get(node.profile_id)
if widget is None:
widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle)
self._nodes[node.profile_id] = widget
widget.update_target(
rank,
node.display_name,
subtitle,
"active" if node.is_active else node.status,
node.quota_status,
node.failover_reason,
)
widget.grid(row=0, column=index, padx=Theme.SPACE_XS, pady=Theme.SPACE_SM, sticky="nsew")
self.chain.grid_columnconfigure(index, weight=1)
active = next((node for node in pipeline.nodes if node.is_active), None)
reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason]
self.footer.configure(
text=(
f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'переключений ещё не было'}"
if active
else "Активный узел: Н/Д — аккаунт не подключён либо все профили недоступны"
)
)
class RoutingView(ctk.CTkFrame):
def __init__(
self, master: Any, routing_data: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action
self._role_widgets: Dict[str, RoutingRoleWidget] = {}
SectionHeader(
self,
title="Маршрутизация",
subtitle="Основной → резерв 1 → резерв 2 → резерв 3; без выдуманных причин failover",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
def focus_role(self, role_id: str) -> None:
"""Focus the existing route editor/card selected in the graph inspector."""
widget = self._role_widgets.get(role_id)
if not widget:
return
for current in self._role_widgets.values():
current.configure(border_color=Theme.BORDER)
widget.configure(border_color=Theme.BORDER_ACCENT)
try:
self.scroll._parent_canvas.yview_moveto(max(0.0, widget.winfo_y() / max(1, self.scroll.winfo_height())))
except (AttributeError, tk.TclError):
pass
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
live_roles = set(snapshot.routing)
for role_id in list(self._role_widgets):
if role_id not in live_roles:
self._role_widgets.pop(role_id).destroy()
for role_id, pipeline in snapshot.routing.items():
widget = self._role_widgets.get(role_id)
if widget is None:
widget = RoutingRoleWidget(self.scroll, pipeline, self.on_action)
widget.pack(fill="x", pady=Theme.SPACE_XS)
self._role_widgets[role_id] = widget
widget.update_from_pipeline(pipeline)

View file

@ -1,122 +0,0 @@
"""Presentation-only settings screen; persistence is delegated to the app action layer."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.ui.components import ActionButton, HubCard, SectionHeader
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.version import CHANNEL, __version__
class SettingsView(ctk.CTkFrame):
def __init__(
self,
master: Any,
on_action: Optional[Callable] = None,
theme_name: str = "dark",
**kwargs,
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action
SectionHeader(
self,
title="Настройки",
subtitle="Маршрутизация, мониторинг и обновления",
action_text="Сохранить",
action_cmd=self._save,
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
appearance = self._section(scroll, "Оформление")
theme_row = ctk.CTkFrame(appearance, fg_color="transparent")
theme_row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ctk.CTkLabel(
theme_row,
text="Цветовая схема",
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left")
self.theme = ctk.CTkOptionMenu(
theme_row,
values=list(Theme.SCHEME_LABELS.values()),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
text_color=Theme.TEXT_PRIMARY,
)
self.theme.set(Theme.SCHEME_LABELS.get(theme_name, Theme.SCHEME_LABELS["dark"]))
self.theme.pack(side="right")
routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
self.affinity = self._switch(routing, "Сессионная привязка", True)
self.failover = self._switch(routing, "Автоматический failover", True)
self.same_account = self._switch(routing, "Сначала менять модель на том же аккаунте", True)
refresh = self._section(scroll, "Мониторинг")
row = ctk.CTkFrame(refresh, fg_color="transparent")
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ctk.CTkLabel(row, text="Интервал обновления квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(
side="left"
)
self.interval = ctk.CTkOptionMenu(
row,
values=["Выкл", "1 мин", "5 мин", "10 мин", "30 мин"],
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
)
self.interval.set("5 мин")
self.interval.pack(side="right")
updates = self._section(scroll, "Обновления")
self.update_status = ctk.CTkLabel(
updates,
text=f"Версия {__version__} • канал {CHANNEL} • состояние обновления: Н",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
)
self.update_status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ActionButton(
updates,
text="Проверить обновления",
variant="secondary",
command=lambda: self.on_action and self.on_action("check_updates", {}),
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
@staticmethod
def _section(master: Any, title: str) -> HubCard:
card = HubCard(master)
card.pack(fill="x", pady=Theme.SPACE_XS)
ctk.CTkLabel(card, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)
)
return card
@staticmethod
def _switch(master: Any, label: str, selected: bool) -> ctk.CTkSwitch:
row = ctk.CTkFrame(master, fg_color="transparent")
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS)
ctk.CTkLabel(row, text=label, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
switch = ctk.CTkSwitch(row, text="", progress_color=Theme.ACCENT)
switch.pack(side="right")
if selected:
switch.select()
return switch
def _save(self) -> None:
if not self.on_action:
return
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
theme_key = next(
(key for key, label in Theme.SCHEME_LABELS.items() if label == self.theme.get()),
"dark",
)
self.on_action(
"save_settings",
{
"session_affinity": bool(self.affinity.get()),
"auto_failover": bool(self.failover.get()),
"prefer_same_account_model_fallback": bool(self.same_account.get()),
"quota_refresh_interval_label": self.interval.get(),
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
"theme": theme_key,
},
)

View file

@ -1,996 +0,0 @@
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
import tkinter as tk
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import (
HubButton,
HubCard,
HubMetricCard,
HubSectionHeader,
HubStatusBadge,
)
from antigravity_provider.router.unified_health import (
AgentViewModel,
STATUS_HEALTHY,
STATUS_QUOTA_LOW,
STATUS_QUOTA_EXHAUSTED,
STATUS_AUTH_REQUIRED,
STATUS_NOT_CONFIGURED,
STATUS_AUTH_EXPIRED,
)
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_AUTH_CHANGED,
EVENT_ACCOUNT_REMOVED,
EVENT_ACCOUNT_UPDATED,
EVENT_QUOTA_UPDATED,
EVENT_ROUTING_UPDATED,
EventBus,
)
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController
def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]:
"""Persist one ordered chain using AutoAssigner while preserving other roles."""
config = load_router_config()
policy = config.roles.get(role_id)
if policy is None:
return False, f"Роль '{role_id}' не найдена"
if len(desired_chain) != len(set(desired_chain)):
return False, "Профиль не может повторяться в одной цепочке"
missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles]
if missing:
return False, f"Профиль '{missing[0]}' не найден"
original = {key: list(value.preferred_chain) for key, value in config.roles.items()}
removed = set(original[role_id]) - set(desired_chain)
affected = {role_id}
for profile_id in removed:
affected.update(key for key, chain in original.items() if profile_id in chain)
ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False)
if not ok:
return False, message
chains = {key: original[key] for key in affected}
chains[role_id] = list(desired_chain)
for target_role, chain in chains.items():
for profile_id in reversed(chain):
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
if not ok:
return False, message
return True, f"Цепочка '{role_id}' сохранена"
class AgentCardWidget(HubCard):
"""Reusable agent card that updates in-place without rebuilding widgets."""
def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs):
super().__init__(master=master, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs)
self.on_action = on_action
self.agent_data: Optional[AgentViewModel] = None
# ── Line 1: Role Title + Badges + Status Dot ──
self.line1 = ctk.CTkFrame(self, fg_color="transparent")
self.line1.pack(fill="x", padx=14, pady=(12, 2))
self.role_lbl = ctk.CTkLabel(self.line1, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.role_lbl.pack(side="left")
self.orch_pill = ctk.CTkFrame(self.line1, fg_color=Theme.ACCENT_DIM, corner_radius=Theme.RADIUS_SM)
self.orch_pill_lbl = ctk.CTkLabel(
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
)
self.orch_pill_lbl.pack(padx=5, pady=1)
self.status_dot = ctk.CTkLabel(
self.line1, text="", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY
)
self.status_dot.pack(side="right")
# ── Line 2: Internal ID + Model ──
self.line2 = ctk.CTkFrame(self, fg_color="transparent")
self.line2.pack(fill="x", padx=14, pady=(0, 4))
self.id_model_lbl = ctk.CTkLabel(self.line2, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.id_model_lbl.pack(anchor="w")
# ── Line 3: Provider with Real Logo ──
self.line3 = ctk.CTkFrame(self, fg_color="transparent")
self.line3.pack(fill="x", padx=14, pady=(2, 2))
self.prov_icon = ctk.CTkLabel(self.line3, text="")
self.prov_icon.pack(side="left", padx=(0, 6))
self.prov_lbl = ctk.CTkLabel(self.line3, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.prov_lbl.pack(side="left")
# ── Line 4: Identity / Account ──
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
self.line4.pack(fill="x", padx=14, pady=(2, 4))
self.identity_lbl = ctk.CTkLabel(
self.line4, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY
)
self.identity_lbl.pack(anchor="w")
self.quota_lbl = ctk.CTkLabel(
self.line4, text="Квота: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0))
# ── Line 5: Role Tag Pills ──
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
self.line5.pack(fill="x", padx=14, pady=(4, 6))
self.pill1 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.pill1.pack(side="left", padx=(0, 6))
self.pill1_lbl = ctk.CTkLabel(self.pill1, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.pill1_lbl.pack(padx=6, pady=2)
self.pill2 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.pill2.pack(side="left", padx=(0, 6))
self.pill2_lbl = ctk.CTkLabel(self.pill2, text="Primary", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.pill2_lbl.pack(padx=6, pady=2)
# ── Line 6: Status & Menu Action ──
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
self.line6.pack(fill="x", padx=14, pady=(4, 12))
self.status_str_lbl = ctk.CTkLabel(
self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY
)
self.status_str_lbl.pack(side="left")
self.menu_btn = ctk.CTkButton(
self.line6,
text="",
width=28,
height=24,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_SECONDARY,
font=("Segoe UI", 12, "bold"),
command=self._open_menu,
)
self.menu_btn.pack(side="right")
self._bind_settings_click(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._open_settings, add="+")
self.bind("<space>", self._open_settings, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_settings_click(self, widget: Any) -> None:
if isinstance(widget, ctk.CTkButton):
return
widget.configure(cursor="hand2")
widget.bind("<Button-1>", self._open_settings, add="+")
for child in widget.winfo_children():
self._bind_settings_click(child)
def _open_settings(self, _event: Any = None) -> None:
if not self.agent_data or not self.on_action:
return
agent = self.agent_data
self.on_action(
"agent_settings",
{
"role_id": agent.role_id,
"profile_id": agent.assigned_profile_id or "",
"provider": agent.provider,
},
)
def _hover_on(self, _event: Any = None) -> None:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
is_orchestrator = bool(self.agent_data and self.agent_data.is_main_orchestrator)
self.configure(
fg_color=Theme.SURFACE,
border_color=Theme.BORDER_ACCENT if is_orchestrator else Theme.BORDER,
)
def update_agent(self, a: AgentViewModel):
self.agent_data = a
is_orch = a.is_main_orchestrator
self.configure(border_color=Theme.BORDER_ACCENT if is_orch else Theme.BORDER)
self.role_lbl.configure(text=a.role_name_ru)
if is_orch:
self.orch_pill.pack(side="left", padx=(8, 0))
else:
self.orch_pill.pack_forget()
# Dot color
dot_color = (
Theme.STATUS_HEALTHY
if a.status == STATUS_HEALTHY
else (
Theme.STATUS_WARNING
if "quota" in a.status or "auth" in a.status or "not_configured" in a.status
else Theme.STATUS_ERROR
)
)
self.status_dot.configure(text_color=dot_color)
# ID + Model
self.id_model_lbl.configure(text=f"{a.assigned_profile_id}{a.model}")
# Provider Icon + Label
p_img = AssetManager.get().get_provider_image(a.provider, size=(18, 18))
if p_img:
self.prov_icon.configure(image=p_img)
self.prov_icon.pack(side="left", padx=(0, 6))
else:
self.prov_icon.pack_forget()
prov = a.provider.lower()
if "antigravity" in prov:
self.prov_lbl.configure(text="Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY)
elif "codex" in prov:
self.prov_lbl.configure(text="OpenAI Codex", text_color=Theme.PROVIDER_CODEX)
elif "opencode" in prov:
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
elif "claude" in prov or "anthropic" in prov:
self.prov_lbl.configure(text="Claude", text_color=Theme.PROVIDER_CLAUDE)
elif "grok" in prov or "xai" in prov:
self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK)
else:
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н")
quota_color = (
Theme.STATUS_HEALTHY
if a.active_quota_status == "healthy"
else (
Theme.STATUS_WARNING
if a.active_quota_status == "warning"
else Theme.STATUS_ERROR
if a.active_quota_status == "exhausted"
else Theme.TEXT_MUTED
)
)
quota_label = a.active_quota_label or "Н/Д — провайдер не отдал лимиты"
session = f" • сессия {a.session_id}" if a.session_id else ""
self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color)
# Pills
self.pill1_lbl.configure(text=a.role_id)
self.pill2_lbl.configure(text=a.routing_position)
# Status text
cd_str = f" ({a.cooldown_remaining_sec}s)" if a.cooldown_remaining_sec > 0 else ""
self.status_str_lbl.configure(text=f"{a.status_label_ru.upper()}{cd_str}", text_color=dot_color)
def _open_menu(self):
if not self.agent_data:
return
a = self.agent_data
pid = a.assigned_profile_id or ""
popup = ctk.CTkToplevel(self.winfo_toplevel())
popup.title(f"Действия: {a.role_name_ru}")
popup.geometry("320x260")
popup.configure(fg_color=Theme.DARK)
popup.resizable(False, False)
popup.transient(self.winfo_toplevel())
popup.grab_set()
popup.update_idletasks()
px = self.winfo_toplevel().winfo_x() + 300
py = self.winfo_toplevel().winfo_y() + 200
popup.geometry(f"+{px}+{py}")
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
c.pack(fill="both", expand=True, padx=10, pady=10)
ctk.CTkLabel(c, text=a.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
ctk.CTkLabel(
c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 10))
def _do(action_name: str):
popup.destroy()
if self.on_action:
self.on_action(
action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name}
)
HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(
fill="x", padx=12, pady=2
)
HubButton(
c,
text="👑 Назначить главным оркестратором",
variant="secondary",
height=30,
command=lambda: _do("set_orchestrator"),
).pack(fill="x", padx=12, pady=2)
HubButton(
c,
text="★ Сделать основным аккаунтом Hermes",
variant="secondary",
height=30,
command=lambda: _do("set_main"),
).pack(fill="x", padx=12, pady=2)
HubButton(
c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all")
).pack(fill="x", padx=12, pady=(2, 0))
class TeamView(ctk.CTkFrame):
"""Interactive canvas over the existing router role/profile chains."""
NODE_W = 218
NODE_H = 104
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.app_state = app_state or {}
self.on_action = on_action
self.controller = RoutingGraphController()
self.snapshot: Optional[HubSnapshot] = None
self._live_pipelines: Dict[str, Any] = {}
self._quota_overrides: Dict[str, Any] = {}
self.selected_role = "orchestrator"
self.selected_edge = ""
self._drag_role = ""
self._drag_origin = (0.0, 0.0)
self._drag_node_origin = (0.0, 0.0)
self._drag_moved = False
self._node_items: Dict[str, tuple[int, ...]] = {}
self._edge_items: Dict[str, tuple[int, ...]] = {}
self._event_bus = EventBus.get()
self._subscribed = False
self._build_static_layout()
self._subscribe_runtime_events()
self._draw_graph(rebuild=True)
self.after(20, self._restore_viewport)
def destroy(self):
if self._subscribed:
for name in self._runtime_events():
self._event_bus.unsubscribe(name, self._on_runtime_event)
self._subscribed = False
super().destroy()
@staticmethod
def _runtime_events() -> tuple[str, ...]:
return (
EVENT_ROUTING_UPDATED,
EVENT_QUOTA_UPDATED,
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_UPDATED,
EVENT_ACCOUNT_REMOVED,
EVENT_ACCOUNT_AUTH_CHANGED,
)
def _subscribe_runtime_events(self) -> None:
if self._subscribed:
return
for name in self._runtime_events():
self._event_bus.subscribe(name, self._on_runtime_event)
self._subscribed = True
def _build_static_layout(self) -> None:
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(12, 8))
titles = ctk.CTkFrame(header, fg_color="transparent")
titles.pack(side="left")
ctk.CTkLabel(titles, text="Граф маршрутизации", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY).pack(
anchor="w"
)
self.state_label = ctk.CTkLabel(
titles, text="Роли и реальные failover-цепочки", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
)
self.state_label.pack(anchor="w")
actions = ctk.CTkFrame(header, fg_color="transparent")
actions.pack(side="right")
for text, command in (
("", self._undo),
("", self._redo),
("Авто", self._auto_layout),
("Вписать", self.fit_to_screen),
("Сохранить", self._save),
):
HubButton(actions, text=text, variant="primary" if text == "Сохранить" else "secondary", command=command).pack(
side="left", padx=3
)
toolbar = ctk.CTkFrame(self, fg_color=Theme.SURFACE, corner_radius=Theme.RADIUS_SM)
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, 7))
self.search = ctk.CTkEntry(toolbar, placeholder_text="Найти роль или профиль…", width=250)
self.search.pack(side="left", padx=8, pady=6)
self.search.bind("<Return>", self._search)
ctk.CTkLabel(toolbar, text="Связь", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(
side="left", padx=(8, 4)
)
self.edge_type = ctk.CTkOptionMenu(toolbar, values=list(EDGE_TYPES), width=112)
self.edge_type.set("DELEGATE")
self.edge_type.pack(side="left", pady=6)
roles = [node.role_id for node in self.controller.graph.nodes] or ["orchestrator"]
self.edge_target = ctk.CTkOptionMenu(toolbar, values=roles, width=135)
self.edge_target.set(next((role for role in roles if role != self.selected_role), roles[0]))
self.edge_target.pack(side="left", padx=(5, 0), pady=6)
profile_ids = list(load_router_config().profiles) or [""]
self.edge_profile = ctk.CTkOptionMenu(toolbar, values=["", *profile_ids], width=140)
self.edge_profile.set("")
self.edge_profile.pack(side="left", padx=(5, 0), pady=6)
HubButton(toolbar, text="Соединить", variant="secondary", command=self._connect_selected).pack(
side="left", padx=5
)
HubButton(toolbar, text="Изменить", variant="secondary", command=self._change_selected_edge).pack(
side="left", padx=(0, 5)
)
self.zoom_label = ctk.CTkLabel(toolbar, text="100%", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.zoom_label.pack(side="right", padx=10)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=4)
body.grid_columnconfigure(1, weight=1)
canvas_frame = ctk.CTkFrame(body, fg_color=Theme.SURFACE_MUTED, border_width=1, border_color=Theme.BORDER)
canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
self.canvas = tk.Canvas(
canvas_frame,
bg=Theme.SURFACE_MUTED,
highlightthickness=0,
xscrollincrement=1,
yscrollincrement=1,
scrollregion=(0, 0, 1800, 1200),
)
self.canvas.pack(fill="both", expand=True)
self.canvas.bind("<ButtonPress-1>", self._on_press)
self.canvas.bind("<B1-Motion>", self._on_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_release)
self.canvas.bind("<MouseWheel>", self._on_zoom)
self.canvas.bind("<ButtonPress-2>", self._start_pan)
self.canvas.bind("<B2-Motion>", self._pan)
self.canvas.bind("<ButtonRelease-2>", self._end_pan)
self.canvas.bind("<Control-z>", lambda _e: self._undo())
self.canvas.bind("<Control-y>", lambda _e: self._redo())
self.canvas.bind("<Delete>", self._delete_selected_edge)
self.canvas.focus_set()
self.minimap = tk.Canvas(canvas_frame, width=155, height=95, bg=Theme.SURFACE, highlightthickness=1)
self.minimap.place(relx=1.0, rely=1.0, x=-12, y=-12, anchor="se")
self.inspector = ctk.CTkFrame(body, fg_color=Theme.SURFACE, border_width=1, border_color=Theme.BORDER)
self.inspector.grid(row=0, column=1, sticky="nsew")
self.inspector_title = ctk.CTkLabel(
self.inspector, text="Инспектор роли", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY
)
self.inspector_title.pack(anchor="w", padx=12, pady=(12, 2))
self.inspector_status = ctk.CTkLabel(
self.inspector, text="", justify="left", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
)
self.inspector_status.pack(fill="x", padx=12, pady=(0, 8))
self.chain_frame = ctk.CTkScrollableFrame(self.inspector, fg_color="transparent")
self.chain_frame.pack(fill="both", expand=True, padx=8)
HubButton(
self.inspector,
text="Открыть маршрутизацию",
variant="primary",
command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}),
).pack(fill="x", padx=10, pady=10)
def _world(self, x: float, y: float) -> tuple[float, float]:
zoom = self.controller.graph.zoom
return self.canvas.canvasx(x) / zoom, self.canvas.canvasy(y) / zoom
def _on_press(self, event: Any) -> None:
item = self.canvas.find_closest(self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))
tags = self.canvas.gettags(item)
edge = next((tag[5:] for tag in tags if tag.startswith("edge:")), "")
if edge:
self.selected_edge = edge
selected = next((item for item in self.controller.graph.edges if item.edge_id == edge), None)
if selected:
self.edge_type.set(selected.edge_type)
self.edge_target.set(selected.target)
self.edge_profile.set(selected.profile_id or "")
self._draw_graph(rebuild=True)
return
role = next((tag[5:] for tag in tags if tag.startswith("role:")), "")
if role:
self.selected_role = role
self.selected_edge = ""
self._drag_role = role
self._drag_moved = False
self._drag_origin = self._world(event.x, event.y)
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
self._drag_node_origin = (node.x, node.y) if node else (0.0, 0.0)
self._update_inspector()
self._update_live_styles()
def _on_drag(self, event: Any) -> None:
if not self._drag_role:
return
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
if not node:
return
x, y = self._world(event.x, event.y)
dx, dy = x - self._drag_origin[0], y - self._drag_origin[1]
if abs(dx) + abs(dy) > 1.5:
self._drag_moved = True
self._drag_origin = (x, y)
node.x += dx
node.y += dy
self.controller.dirty = True
self._draw_graph(rebuild=True)
def _on_release(self, _event: Any) -> None:
if self._drag_role:
clicked_role = self._drag_role
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
if node:
final_x, final_y = node.x, node.y
node.x, node.y = self._drag_node_origin
self.controller.move_node(node.role_id, final_x, final_y)
self._drag_role = ""
self._set_dirty_text()
if not self._drag_moved and self.on_action:
pipeline = self._pipeline_for(clicked_role)
active_profile = pipeline.active_profile_id if pipeline else ""
active_node = next(
(item for item in list(getattr(pipeline, "nodes", []) or []) if item.profile_id == active_profile),
None,
)
self.on_action(
"agent_settings",
{
"role_id": clicked_role,
"profile_id": active_profile,
"provider": getattr(active_node, "provider", ""),
},
)
def _on_zoom(self, event: Any) -> str:
factor = 1.1 if event.delta > 0 else 0.9
self.controller.graph.zoom = max(0.45, min(1.8, self.controller.graph.zoom * factor))
self.controller.dirty = True
self._draw_graph(rebuild=True)
self._set_dirty_text()
return "break"
def _start_pan(self, event: Any) -> None:
self.canvas.scan_mark(event.x, event.y)
def _pan(self, event: Any) -> None:
self.canvas.scan_dragto(event.x, event.y, gain=1)
def _end_pan(self, _event: Any) -> None:
zoom = self.controller.graph.zoom
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
self.controller.dirty = True
self._set_dirty_text()
def _restore_viewport(self) -> None:
graph = self.controller.graph
z = graph.zoom
self.canvas.xview_moveto(max(0.0, graph.viewport_x * z / 1800.0))
self.canvas.yview_moveto(max(0.0, graph.viewport_y * z / 1200.0))
def _node_coords(self, role_id: str) -> tuple[float, float, float, float]:
node = next(item for item in self.controller.graph.nodes if item.role_id == role_id)
z = self.controller.graph.zoom
return node.x * z, node.y * z, (node.x + self.NODE_W) * z, (node.y + self.NODE_H) * z
def _draw_graph(self, rebuild: bool = True) -> None:
if rebuild:
self.canvas.delete("all")
self._node_items.clear()
self._edge_items.clear()
z = self.controller.graph.zoom
for edge in self.controller.graph.edges:
try:
sx1, sy1, sx2, sy2 = self._node_coords(edge.source)
tx1, ty1, _tx2, ty2 = self._node_coords(edge.target)
except StopIteration:
continue
start = (sx2, (sy1 + sy2) / 2)
end = (tx1, (ty1 + ty2) / 2)
dash = () if edge.edge_type == "PRIMARY" else (7, 4) if edge.edge_type == "FALLBACK" else (2, 4)
width = 3 if edge.edge_type == "PRIMARY" else 2
line = self.canvas.create_line(
*start,
*end,
smooth=True,
arrow="last",
width=width,
dash=dash,
fill=Theme.ACCENT if edge.edge_id == self.selected_edge else Theme.TEXT_ACCENT,
tags=(f"edge:{edge.edge_id}", "edge"),
)
label = self.canvas.create_text(
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2 - 9,
text=edge.edge_type,
fill=Theme.TEXT_MUTED,
font=("Segoe UI", max(7, int(8 * z)), "bold"),
tags=(f"edge:{edge.edge_id}", "edge"),
)
self._edge_items[edge.edge_id] = (line, label)
config = load_router_config()
for node in self.controller.graph.nodes:
x1, y1, x2, y2 = self._node_coords(node.role_id)
pipeline = self._pipeline_for(node.role_id)
active = pipeline.active_profile_id if pipeline else ""
chain = list(config.roles.get(node.role_id).preferred_chain) if node.role_id in config.roles else []
selected = node.role_id == self.selected_role
rect = self.canvas.create_rectangle(
x1,
y1,
x2,
y2,
width=2 if selected else 1,
outline=Theme.ACCENT if selected else Theme.BORDER,
fill=Theme.SURFACE,
tags=(f"role:{node.role_id}", "node"),
)
title = self.canvas.create_text(
x1 + 12 * z,
y1 + 17 * z,
anchor="w",
text=node.label or node.role_id,
fill=Theme.TEXT_PRIMARY,
font=("Segoe UI", max(9, int(11 * z)), "bold"),
tags=(f"role:{node.role_id}", "node"),
)
active_text = f"Активен: {active}" if active else "Активный профиль: Н"
meta = self.canvas.create_text(
x1 + 12 * z,
y1 + 43 * z,
anchor="w",
text=active_text,
fill=Theme.STATUS_HEALTHY if active else Theme.TEXT_MUTED,
font=("Segoe UI", max(7, int(8 * z))),
tags=(f"role:{node.role_id}", "node"),
)
chain_text = "".join(chain[:3]) if chain else "Нет профилей"
chain_item = self.canvas.create_text(
x1 + 12 * z,
y1 + 68 * z,
anchor="w",
width=(self.NODE_W - 24) * z,
text=chain_text,
fill=Theme.TEXT_SECONDARY,
font=("Segoe UI", max(7, int(8 * z))),
tags=(f"role:{node.role_id}", "node"),
)
self._node_items[node.role_id] = (rect, title, meta, chain_item)
self.zoom_label.configure(text=f"{self.controller.graph.zoom * 100:.0f}%")
bounds = self.canvas.bbox("all")
if bounds:
self.canvas.configure(scrollregion=(0, 0, max(1800, bounds[2] + 120), max(1200, bounds[3] + 120)))
self._draw_minimap()
self._update_inspector()
def _draw_minimap(self) -> None:
self.minimap.delete("all")
if not self.controller.graph.nodes:
return
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
scale = min(145 / max(max_x, 1), 85 / max(max_y, 1))
for node in self.controller.graph.nodes:
self.minimap.create_rectangle(
5 + node.x * scale,
5 + node.y * scale,
5 + (node.x + self.NODE_W) * scale,
5 + (node.y + self.NODE_H) * scale,
outline=Theme.ACCENT if node.role_id == self.selected_role else Theme.BORDER,
fill=Theme.SURFACE_MUTED,
)
def _update_inspector(self) -> None:
for child in self.chain_frame.winfo_children():
child.destroy()
role = self.selected_role
pipeline = self._pipeline_for(role)
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
self.inspector_title.configure(text=node.label if node else role)
self.inspector_status.configure(
text=(
f"Активный: {pipeline.active_profile_id}"
if pipeline and pipeline.active_profile_id
else "Активный: Н/Д — профиль не назначен"
)
)
config = load_router_config()
policy = config.roles.get(role)
chain = list(policy.preferred_chain) if policy else []
live_nodes = {item.profile_id: item for item in pipeline.nodes} if pipeline else {}
for index, profile_id in enumerate(chain):
profile = config.profiles.get(profile_id)
live = live_nodes.get(profile_id)
card = HubCard(self.chain_frame)
card.pack(fill="x", pady=4)
rank = "PRIMARY" if index == 0 else f"FALLBACK {index}"
ctk.CTkLabel(card, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(
anchor="w", padx=8, pady=(6, 0)
)
ctk.CTkLabel(
card, text=profile_id, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(anchor="w", padx=8)
provider = profile.provider if profile else "Н/Д — аккаунт не подключён"
model = live.model if live else (
profile.preferred_models[0]
if profile and profile.preferred_models
else "Н/Д — список моделей ещё не получен"
)
identity = live.account_identity if live and live.account_identity else "Аккаунт: Н"
quota = live.quota_status if live else "Н/Д — аккаунт не подключён"
if profile_id in self._quota_overrides:
raw_quota = self._quota_overrides[profile_id]
quota = getattr(raw_quota, "status", None) or getattr(raw_quota, "quota_status", None) or quota
if str(quota).strip().lower() in {"", "unknown", "none", "not_configured"}:
quota = "Н/Д — провайдер не отдал лимиты"
reason = live.failover_reason if live and live.failover_reason else "переключений ещё не было"
ctk.CTkLabel(
card,
text=f"{provider}{model}\n{identity}\nКвота: {quota}\nFailover: {reason}",
justify="left",
anchor="w",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
).pack(fill="x", padx=8, pady=(2, 7))
controls = ctk.CTkFrame(card, fg_color="transparent")
controls.pack(fill="x", padx=6, pady=(0, 6))
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, -1),
).pack(side="left", padx=2)
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, 1),
).pack(side="left", padx=2)
HubButton(
controls,
text="Удалить",
variant="ghost",
width=76,
height=26,
command=lambda pid=profile_id: self._remove_chain_profile(pid),
).pack(side="right", padx=2)
if not chain:
ctk.CTkLabel(
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
).pack(anchor="w", padx=6, pady=8)
available = [profile_id for profile_id in config.profiles if profile_id not in chain]
add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent")
add_row.pack(fill="x", pady=(8, 2))
add_menu = ctk.CTkOptionMenu(
add_row,
values=available or [""],
font=Theme.font_caption(),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
button_hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4))
HubButton(
add_row,
text="Добавить",
variant="secondary",
width=88,
command=lambda: self._add_chain_profile(add_menu.get()),
state="normal" if available else "disabled",
).pack(side="right")
def _persist_selected_chain(self, chain: List[str]) -> None:
ok, message = persist_role_chain(self.selected_role, chain)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
if ok:
self._update_inspector()
def _move_chain_profile(self, profile_id: str, direction: int) -> None:
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
return
source = chain.index(profile_id)
target = source + direction
if target < 0 or target >= len(chain):
self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING)
return
chain[source], chain[target] = chain[target], chain[source]
self._persist_selected_chain(chain)
def _remove_chain_profile(self, profile_id: str) -> None:
chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id]
self._persist_selected_chain(chain)
def _add_chain_profile(self, profile_id: str) -> None:
if not profile_id or profile_id == "":
return
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
chain.append(profile_id)
self._persist_selected_chain(chain)
def _on_runtime_event(self, name: str, data: Any) -> None:
# EventBus may publish from a worker. Tk mutation is always marshalled.
try:
self.after(0, lambda: self._apply_runtime_event(name, data))
except Exception:
pass
def _apply_runtime_event(self, name: str, data: Any) -> None:
payload = data if isinstance(data, dict) else {}
if name == EVENT_ROUTING_UPDATED and payload.get("pipeline") is not None:
self._live_pipelines[str(payload.get("role_id", ""))] = payload["pipeline"]
elif name == EVENT_QUOTA_UPDATED and payload.get("profile_id"):
self._quota_overrides[str(payload["profile_id"])] = payload.get("quota_snapshot") or payload.get("snapshot")
self._update_live_styles()
def _pipeline_for(self, role_id: str) -> Any:
if role_id in self._live_pipelines:
return self._live_pipelines[role_id]
return self.snapshot.routing.get(role_id) if self.snapshot else None
def _update_live_styles(self) -> None:
"""Update existing canvas items; runtime events never rebuild the canvas."""
if not self.snapshot:
return
for role_id, items in self._node_items.items():
pipeline = self._pipeline_for(role_id)
active = pipeline.active_profile_id if pipeline else ""
self.canvas.itemconfigure(items[0], outline=Theme.ACCENT if role_id == self.selected_role else Theme.BORDER)
self.canvas.itemconfigure(items[2], text=f"Активен: {active}" if active else "Активный профиль: Н")
self._update_inspector()
def update_data(self, snapshot: Optional[Any] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self.snapshot = snapshot
self._live_pipelines = {
role_id: pipeline
for role_id, pipeline in self._live_pipelines.items()
if role_id in snapshot.routing and pipeline is not snapshot.routing[role_id]
}
if set(snapshot.routing) != set(self._node_items):
self._draw_graph(rebuild=True)
else:
self._update_live_styles()
def _search(self, _event: Any = None) -> str:
query = self.search.get().strip().lower()
config = load_router_config()
for node in self.controller.graph.nodes:
chain = config.roles.get(node.role_id).preferred_chain if node.role_id in config.roles else []
if query in node.role_id.lower() or query in node.label.lower() or any(query in item.lower() for item in chain):
self.selected_role = node.role_id
self._draw_graph(rebuild=True)
break
return "break"
def focus_role(self, role_id: str) -> None:
"""Select a role when Routing delegates editing to this single editor."""
if not any(node.role_id == role_id for node in self.controller.graph.nodes):
self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING)
return
self.selected_role = role_id
self.selected_edge = ""
target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id)
self.edge_target.set(target)
self._draw_graph(rebuild=True)
self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT)
def _connect_selected(self) -> None:
if len(self.controller.graph.nodes) < 2:
return
source = self.selected_role
target = self.edge_target.get()
profile_id = self.edge_profile.get()
profile_id = "" if profile_id == "" else profile_id
ok, message = self.controller.add_edge(source, target, self.edge_type.get(), profile_id)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _change_selected_edge(self) -> None:
if not self.selected_edge:
self.state_label.configure(text="Сначала выберите связь на графе", text_color=Theme.STATUS_WARNING)
return
profile_id = self.edge_profile.get()
ok, message = self.controller.set_edge_type(
self.selected_edge,
self.edge_type.get(),
"" if profile_id == "" else profile_id,
)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _delete_selected_edge(self, _event: Any = None) -> str:
selected = self.canvas.find_withtag("current")
edge_id = self.selected_edge
if selected:
edge_id = next((tag[5:] for tag in self.canvas.gettags(selected[0]) if tag.startswith("edge:")), "")
if edge_id:
self.controller.delete_edge(edge_id)
self.selected_edge = ""
self._draw_graph(rebuild=True)
self._set_dirty_text()
return "break"
def _auto_layout(self) -> None:
self.controller.auto_layout()
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _undo(self) -> None:
if self.controller.undo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _redo(self) -> None:
if self.controller.redo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
def fit_to_screen(self) -> None:
if not self.controller.graph.nodes:
return
self.update_idletasks()
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
self.controller.graph.zoom = max(0.45, min(1.4, min(self.canvas.winfo_width() / max_x, self.canvas.winfo_height() / max_y) * 0.9))
self.controller.dirty = True
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _save(self) -> None:
zoom = self.controller.graph.zoom
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
issues = self.controller.save()
if issues:
self._show_issues(issues)
return
self.state_label.configure(text="Сохранено • позиции и масштаб переживут перезапуск", text_color=Theme.STATUS_HEALTHY)
def _show_issues(self, issues: List[GraphIssue]) -> None:
bad_nodes = {issue.node_id for issue in issues if issue.node_id}
for role_id, items in self._node_items.items():
self.canvas.itemconfigure(items[0], outline=Theme.STATUS_ERROR if role_id in bad_nodes else Theme.BORDER)
message = "".join(issue.message for issue in issues[:3])
if len(issues) > 3:
message += f" • ещё {len(issues) - 3}"
self.state_label.configure(text=message, text_color=Theme.STATUS_ERROR)
def _set_dirty_text(self) -> None:
self.state_label.configure(
text="● Есть несохранённые изменения" if self.controller.dirty else "Роли и реальные failover-цепочки",
text_color=Theme.STATUS_WARNING if self.controller.dirty else Theme.TEXT_MUTED,
)
def _trigger_action(self, action: str, profile: Dict[str, Any]) -> None:
if self.on_action:
self.on_action(action, profile)

View file

@ -98,6 +98,7 @@ class ProfileViewModel:
quota_snapshot: Optional[Any] = None
preferred_models: List[str] = field(default_factory=list)
active_leases: int = 0
request_options: dict[str, Any] = field(default_factory=dict)
@property
def auth_label_ru(self) -> str:
@ -352,7 +353,8 @@ class UnifiedHealthService:
tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})"
role_assignments.setdefault(pid, []).append(tag)
orch_primary = config.roles.get("orchestrator", None)
orch_role_name = RoleRegistry.resolve_canonical_role("orchestrator")
orch_primary = config.roles.get(orch_role_name, None)
orch_primary_id = orch_primary.preferred_chain[0] if orch_primary and orch_primary.preferred_chain else ""
now = time.time()
@ -529,6 +531,7 @@ class UnifiedHealthService:
quota_snapshot=snap,
preferred_models=pcfg.preferred_models,
active_leases=precord.active_leases,
request_options=dict(pcfg.request_options or {}),
)
result.setdefault(prov, []).append(vm)
@ -712,6 +715,26 @@ class UnifiedHealthService:
continue
if not chain:
agents.append(AgentViewModel(
role_id=rname,
role_name_ru=rname_ru,
role_description_ru=rdesc_ru or "Роль готова к назначению аккаунтов",
assigned_profile_id=None,
assigned_display_name=None,
provider="N/A",
provider_display_name="Не назначен",
model="-",
account_identity="Аккаунт не назначен",
routing_position="Не назначен",
status="not_configured",
status_label_ru="Не настроен",
is_active=False,
is_main_orchestrator=(rname == "manager"),
cooldown_remaining_sec=0,
session_id=None,
active_quota_status="unavailable",
active_quota_label="Не применяется",
))
continue
primary_pid = chain[0]
@ -761,6 +784,27 @@ class UnifiedHealthService:
active_quota_status=active_quota_st,
active_quota_label=active_quota_lbl,
))
else:
agents.append(AgentViewModel(
role_id=rname,
role_name_ru=rname_ru,
role_description_ru=rdesc_ru or "Роль готова к назначению аккаунтов",
assigned_profile_id=primary_pid,
assigned_display_name=primary_pid,
provider="N/A",
provider_display_name="Не назначен",
model="-",
account_identity="Аккаунт не найден",
routing_position="Primary",
status="not_configured",
status_label_ru="Не настроен",
is_active=False,
is_main_orchestrator=(rname == "manager"),
cooldown_remaining_sec=0,
session_id=None,
active_quota_status="unavailable",
active_quota_label="Не применяется",
))
return agents

View file

@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional
from antigravity_provider import paths
from antigravity_provider.version import __version__
from fastapi import FastAPI, Request, HTTPException, Depends, Header
from fastapi import FastAPI, Request, HTTPException, Depends, Header, Response
from pathlib import Path
from fastapi.responses import FileResponse, JSONResponse
@ -246,6 +246,30 @@ def get_events(limit: int = 100, category: Optional[str] = None, authorized: boo
return JSONResponse(content=jsonable_encoder({"events": sanitized}))
@app.get("/api/quotas/export")
def export_quotas_endpoint(
format: str = "json",
authorized: bool = Depends(get_auth_token),
):
"""Export full quotas and limits report across all providers and profiles."""
from antigravity_provider.router.action_handler import generate_quotas_export
fmt = format.lower().strip()
if fmt == "csv":
csv_data = generate_quotas_export(format="csv")
return Response(
content=csv_data,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=hermes_quotas_export.csv"},
)
else:
json_data = generate_quotas_export(format="json")
sanitized = sanitize_snapshot(json_data)
return JSONResponse(
content=jsonable_encoder(sanitized),
headers={"Content-Disposition": "attachment; filename=hermes_quotas_export.json"},
)
@app.get("/api/settings")
def get_settings(authorized: bool = Depends(get_auth_token)):
"""Return current server and hub settings without exposing raw auth tokens."""
@ -282,21 +306,46 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
settings_out[k] = v
return JSONResponse(content=jsonable_encoder(settings_out))
def run_server():
def run_web_server(host: Optional[str] = None, port: Optional[int] = None, open_browser: bool = False) -> None:
import uvicorn
import webbrowser
settings = _web_settings()
host = settings.get('web_api_host', '127.0.0.1')
port = int(settings.get('web_api_port', 5800))
host = host or settings.get('web_api_host', '127.0.0.1')
port = port or int(settings.get('web_api_port', 5800))
token = settings.get('web_api_token', '')
if host != '127.0.0.1' and not token:
logger.error("Cannot bind Web API externally without web_api_token")
sys.exit(1)
logger.info(f"Starting Hermes Hub Web API on {host}:{port}")
url = f"http://{host}:{port}"
logger.info(f"Starting Hermes Hub Web on {url}")
print(f"Hermes Hub running at {url}")
if open_browser:
def _open():
time.sleep(0.5)
try:
webbrowser.open(url)
except Exception:
pass
threading.Thread(target=_open, daemon=True).start()
uvicorn.run(app, host=host, port=port, log_level="warning")
def start_web_server_background(host: Optional[str] = None, port: Optional[int] = None) -> threading.Thread:
"""Start web server in a daemon thread."""
t = threading.Thread(target=run_web_server, kwargs={"host": host, "port": port, "open_browser": False}, daemon=True)
t.start()
return t
def run_server() -> None:
run_web_server(open_browser=False)
# ─────────────────────────────────────────────────────────────
# Статика: сервер обязан отдавать сам интерфейс, а не только API.
# Контракт описывал каталог static/, но не назвал, кто его монтирует,

File diff suppressed because it is too large Load diff

View file

@ -158,6 +158,10 @@
<option value="opencode-go">OpenCode Go</option>
<option value="claude">Claude (Anthropic)</option>
<option value="grok">Grok (xAI)</option>
<option value="ollama">Ollama</option>
<option value="local">Локальные (Local LLM)</option>
<option value="openrouter">OpenRouter</option>
<option value="nvidia">NVIDIA NIM</option>
</select>
<select id="filter-health" class="select-filter">
<option value="all">Все состояния</option>
@ -167,6 +171,9 @@
<option value="auth_required">Требуется вход</option>
<option value="disabled">Отключён / Резерв</option>
</select>
<button class="btn btn-secondary btn-sm" id="btn-export-quotas" onclick="openExportQuotasModal()" title="Выгрузить отчет о лимитах и квотах в формате JSON или CSV">
<span>📥 Экспорт лимитов (JSON / CSV)</span>
</button>
<div class="toolbar-stats" id="accounts-stats-summary">
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
</div>
@ -438,6 +445,14 @@
<select id="setting-theme" class="select-filter">
<option value="system">Системная (по умолчанию)</option>
<option value="dark">Тёмная (Dark)</option>
<!-- Medium была полностью реализована в style.css, но в этот
список не попала, и выбрать её было нельзя. Брендбук
требует три темы, причём Medium — отдельная, а не
осветлённая Dark: тёмно-зелёный холст #1A2A1F со
светлыми кремовыми карточками #F7F1E3. Проверено
подстановкой data-theme вручную: тема отрисовывается
верно и отличается от Dark и по фону, и по карточкам. -->
<option value="medium">Средняя (Medium)</option>
<option value="light">Светлая (Light)</option>
</select>
</div>
@ -542,6 +557,20 @@
<button class="btn btn-secondary" id="btn-save-client-settings">Сохранить локальные параметры</button>
</div>
</div>
<!-- Clean Setup / Reset Configuration (P0-4) -->
<div class="settings-card" style="margin-top:16px; border:1px solid rgba(220, 53, 69, 0.3);">
<h2 class="settings-group-title" style="color:var(--status-error, #e57373);">Сброс конфигурации</h2>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Начать настройку заново</div>
<div class="setting-desc">Сбросить все цепочки ролей и список профилей в чистое начальное состояние. Все учетные данные и токены аккаунтов сохраняются.</div>
</div>
<div class="setting-control">
<button class="btn btn-danger" id="btn-reset-router-config">Начать настройку заново</button>
</div>
</div>
</div>
</section>
</div>
</main>

View file

@ -31,7 +31,7 @@
.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); }
.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); }
.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; }
.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; }
.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; text-anchor:middle; paint-order:stroke; stroke:var(--bg-base); stroke-width:3px; stroke-linejoin:round; }
.workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; }
.workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; }
.workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); }

View file

@ -386,8 +386,18 @@ function renderAgentModelTab(content, snapshot, agent) {
};
const refreshModels = () => {
const profile = profiles.find((item) => item.profile_id === accountSelect.value);
const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean);
modelSelect.innerHTML = models.length ? models.map((model) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: модели не обнаружены</option>';
// Список моделей даёт провайдер. preferred_models — это настроенный владельцем
// список предпочтений профиля, а model_states строится перебором того же
// preferred_models (unified_health.py), поэтому обе прежние ветки показывали
// один и тот же куцый набор, и владелец видел «модели уже распределены».
const summary = (snapshot.providers || []).find((item) => item.provider_id === (profile?.provider || providerSelect.value));
const discovered = summary?.discovered_models || [];
const models = discovered.length ? [...discovered] : [...(profile?.preferred_models || [])];
// Настроенная у агента модель обязана присутствовать в списке. Иначе ни один
// option не получал selected, браузер показывал первый вариант, и нажатие
// «Изменить конфигурацию» молча записывало агенту не ту модель.
if (cfg.model && !models.includes(cfg.model)) models.unshift(cfg.model);
modelSelect.innerHTML = models.length ? models.map((model) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: список моделей ещё не получен от провайдера</option>';
};
providerSelect.onchange = refreshAccounts;
accountSelect.onchange = refreshModels;

View file

@ -97,35 +97,29 @@ def block_external_network_in_hermetic_tests(request, monkeypatch):
def pytest_configure(config):
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment")
config.addinivalue_line("markers", "ui: mark test as requiring UI environment")
# Переменные окружения провайдеров не должны протекать в тесты.
#
# На машине владельца задана ANTHROPIC_BASE_URL=https://api.anthropic.com, и
# из-за неё test_claude_health_check_real_probe падал: адаптер строил
# ".../models" вместо ".../v1/models", хотя код верен, а подставилось значение
# из окружения. Набор обязан давать одинаковый результат на любой машине —
# это то же требование герметичности, ради которого делался A33.
_PROVIDER_BASE_URL_VARS = (
"ANTHROPIC_BASE_URL",
"CODEX_BASE_URL",
"DEEPSEEK_BASE_URL",
"LOCAL_LLM_BASE_URL",
"NVIDIA_BASE_URL",
"OLLAMA_BASE_URL",
"OPENCODE_GO_BASE_URL",
"OPENROUTER_BASE_URL",
)
def pytest_collection_modifyitems(config, items):
"""Skip tests explicitly marked as needing the Tk graphical stack.
Selection is by the ``ui`` marker only. It used to also match any test whose
*name* contained "ui", "view" or "wizard", which silently skipped static
checks that never touch the toolkit and hid real failures for weeks.
Modules that import the GUI stack guard themselves with
``pytest.importorskip("customtkinter")`` at module scope; that guard is
enforced by tests/test_import_invariants.py.
"""
try:
import customtkinter # noqa: F401
except Exception:
skip_ui = pytest.mark.skip(reason="customtkinter is not installed in current environment")
for item in items:
if "ui" in item.keywords:
item.add_marker(skip_ui)
@pytest.fixture(scope="session")
def tk_root():
"""Shared Tkinter root for all UI tests to avoid Tcl resource exhaustion."""
try:
import customtkinter as ctk
root = ctk.CTk()
root.withdraw()
yield root
root.destroy()
except Exception as e:
pytest.skip(f"Tkinter could not be initialized: {e}")
@pytest.fixture(autouse=True)
def _isolate_provider_base_urls(monkeypatch):
"""Убрать адреса провайдеров из окружения на время каждого теста."""
for name in _PROVIDER_BASE_URL_VARS:
monkeypatch.delenv(name, raising=False)

View file

@ -0,0 +1,360 @@
"""Unit and integration tests for Task A39: Request options for local profiles.
Tests:
1. RouterProfileConfig schema, YAML serialization, and persistence across save/load.
2. LocalLLMAdapter payload merging with nested structures (e.g. chat_template_kwargs).
3. Precedence of explicit request parameters over request_options and warning logging.
4. Provider isolation: ensure non-local adapters never leak request_options.
5. Error parsing and classification for invalid/unknown parameters.
6. Action handler save_request_options execution.
7. Web UI contract in app.js for request options editor, live preview, and validation.
8. Clean codebase: absence of hardcoded parameter keys in adapter/router logic.
"""
from __future__ import annotations
import json
import logging
import pathlib
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.adapters.base_adapter import ErrorCategory
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
load_router_config,
save_router_config,
)
from antigravity_provider.router.action_handler import ActionExecutor, do_save_request_options
from antigravity_provider.router.unified_health import UnifiedHealthService
class TestProfileConfigAndPersistence:
"""Test RouterProfileConfig schema and YAML persistence."""
def test_profile_config_defaults(self):
pcfg = RouterProfileConfig(
profile_id="local-test",
provider="local",
)
assert pcfg.request_options == {}
def test_profile_config_custom_options_and_nested_dict(self):
options = {
"chat_template_kwargs": {"enable_thinking": False},
"seed": 42,
"top_k": 40,
}
pcfg = RouterProfileConfig(
profile_id="local-test",
provider="local",
request_options=options,
)
assert pcfg.request_options == options
assert pcfg.request_options["chat_template_kwargs"]["enable_thinking"] is False
def test_yaml_roundtrip_preserves_nested_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf"],
request_options={
"chat_template_kwargs": {"enable_thinking": False},
"seed": 1234,
"custom_flag": True,
},
),
"ag-w1": RouterProfileConfig(
profile_id="ag-w1",
provider="antigravity",
request_options={},
),
}
)
assert save_router_config(cfg, cfg_path)
assert cfg_path.is_file()
loaded = load_router_config(cfg_path)
loaded_p = loaded.get_profile("local-1")
assert loaded_p is not None
assert loaded_p.request_options == {
"chat_template_kwargs": {"enable_thinking": False},
"seed": 1234,
"custom_flag": True,
}
assert loaded_p.request_options["chat_template_kwargs"]["enable_thinking"] is False
ag_p = loaded.get_profile("ag-w1")
assert ag_p is not None
assert ag_p.request_options == {}
def test_unified_health_profile_view_model_includes_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={"chat_template_kwargs": {"enable_thinking": False}},
)
}
)
save_router_config(cfg, cfg_path)
with patch.dict("os.environ", {"HERMES_ROUTER_CONFIG": str(cfg_path)}):
uh = UnifiedHealthService.get()
profs = uh.scan_all(force=True)
local_list = profs.get("local", [])
matching = [p for p in local_list if p.profile_id == "local-1"]
assert len(matching) == 1
assert matching[0].request_options == {"chat_template_kwargs": {"enable_thinking": False}}
class TestLocalLLMAdapterRequestOptions:
"""Test LocalLLMAdapter.invoke merging of request_options and precedence handling."""
def test_invoke_merges_nested_request_options_into_payload(self):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
custom_base_url="http://127.0.0.1:8081/v1",
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf"],
request_options={
"chat_template_kwargs": {"enable_thinking": False},
"presence_penalty": 0.5,
},
)
request = {
"messages": [{"role": "user", "content": "Hello world"}],
"max_tokens": 500,
}
mock_resp_data = {
"id": "chatcmpl-1",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}],
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8")
mock_response.__enter__.return_value = mock_response
with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen:
resp = adapter.invoke(profile, request)
assert resp == mock_resp_data
req_arg = mock_urlopen.call_args[0][0]
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["chat_template_kwargs"] == {"enable_thinking": False}
assert payload["presence_penalty"] == 0.5
assert payload["max_tokens"] == 500
assert payload["messages"] == [{"role": "user", "content": "Hello world"}]
assert payload["model"] == "Qwen3.8-27B-Q4_K_M.gguf"
def test_explicit_request_parameters_override_request_options_and_log_warning(self, caplog):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={
"max_tokens": 4096,
"temperature": 0.1,
"presence_penalty": 0.2,
},
)
request = {
"messages": [{"role": "user", "content": "test"}],
"max_tokens": 1500, # Explicit request parameter wins
"temperature": 0.8, # Explicit request parameter wins
}
mock_resp_data = {
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8")
mock_response.__enter__.return_value = mock_response
with caplog.at_level(logging.WARNING):
with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["max_tokens"] == 1500
assert payload["temperature"] == 0.8
assert payload["presence_penalty"] == 0.2
# Verify warnings logged for conflicts
warnings = [record.message for record in caplog.records if record.levelno >= logging.WARNING]
assert any("max_tokens" in w for w in warnings)
assert any("temperature" in w for w in warnings)
def test_temperature_from_request_options_used_when_request_omits_temperature(self):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={"temperature": 0.2},
)
request = {
"messages": [{"role": "user", "content": "test"}],
}
mock_resp_data = {
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8")
mock_response.__enter__.return_value = mock_response
with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["temperature"] == 0.2
class TestProviderIsolation:
"""Verify other provider adapters never leak request_options."""
@pytest.mark.parametrize(
"provider_name",
["antigravity", "openai-codex", "claude", "grok", "opencode-go"],
)
def test_non_local_adapters_do_not_inject_arbitrary_request_options(self, provider_name):
adapter = get_adapter(provider_name)
assert not isinstance(adapter, LocalLLMAdapter)
import inspect
src = inspect.getsource(adapter.invoke)
assert "request_options" not in src, f"{provider_name} adapter must not reference request_options"
class TestErrorHandlingAndClassification:
"""Test error parsing and error classification for local provider."""
def test_unknown_parameter_http_400_raises_runtime_error_with_extracted_message(self):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={"invalid_param": 123},
)
request = {"messages": [{"role": "user", "content": "hi"}]}
import io
import urllib.error
error_body = json.dumps({"error": {"message": "unknown parameter 'invalid_param'", "type": "invalid_request_error"}}).encode("utf-8")
http_err = urllib.error.HTTPError("http://127.0.0.1:8081/v1/chat/completions", 400, "Bad Request", {}, io.BytesIO(error_body))
with patch("urllib.request.urlopen", side_effect=http_err):
with pytest.raises(RuntimeError) as exc_info:
adapter.invoke(profile, request)
err_str = str(exc_info.value)
assert "Local LLM API Error (400)" in err_str
assert "unknown parameter 'invalid_param'" in err_str
def test_classify_error_for_invalid_request(self):
adapter = LocalLLMAdapter()
exc = RuntimeError("Local LLM API Error (400): unknown parameter 'foo'")
classification = adapter.classify_error(exc)
assert classification.category == ErrorCategory.INVALID_REQUEST
assert "unknown parameter" in classification.message
class TestActionHandlerAndWebUI:
"""Test action execution and web client contracts for request_options."""
def test_do_save_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
preferred_models=["default"],
)
}
)
save_router_config(cfg, cfg_path)
with patch.dict("os.environ", {"HERMES_ROUTER_CONFIG": str(cfg_path)}):
ok, msg = do_save_request_options("local-1", {"chat_template_kwargs": {"enable_thinking": False}})
assert ok is True
assert "успешно сохранены" in msg
# Verify persistence
loaded = load_router_config(cfg_path)
assert loaded.get_profile("local-1").request_options == {
"chat_template_kwargs": {"enable_thinking": False}
}
def test_action_executor_save_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
)
}
)
save_router_config(cfg, cfg_path)
with patch.dict("os.environ", {"HERMES_ROUTER_CONFIG": str(cfg_path)}):
res = ActionExecutor.execute(
"save_request_options",
{
"profile_id": "local-1",
"request_options": {"chat_template_kwargs": {"enable_thinking": False}},
},
)
assert res.get("ok") is True
def test_action_executor_invalid_json_fails_gracefully(self):
ok, msg = do_save_request_options("local-1", "{invalid json...")
assert ok is False
assert "Некорректный JSON" in msg
def test_app_js_contains_request_options_modal_and_validation(self):
app_js = pathlib.Path("src/antigravity_provider/router/web/static/app.js").read_text(encoding="utf-8")
assert "modal-request-options-input" in app_js
assert "updateRequestOptionsPreview" in app_js
assert "handleSaveRequestOptions" in app_js
assert "modal-payload-preview-content" in app_js
assert "save_request_options" in app_js
assert "openAccountDetailsModal" in app_js
class TestNoHardcodedConstants:
"""Verify neither enable_thinking nor reasoning_effort is hardcoded in local adapter or router config logic."""
def test_no_hardcoded_keys_in_local_adapter_and_router_config(self):
src_files = [
pathlib.Path("src/antigravity_provider/router/adapters/local_adapter.py"),
pathlib.Path("src/antigravity_provider/router/router_config.py"),
pathlib.Path("src/antigravity_provider/router/action_handler.py"),
]
for py_file in src_files:
text = py_file.read_text(encoding="utf-8")
lines = text.splitlines()
for idx, line in enumerate(lines, 1):
clean = line.strip()
if clean.startswith("#"):
continue
assert "enable_thinking" not in clean, f"Hardcoded enable_thinking found in {py_file.name}:{idx}: {line}"
assert "reasoning_effort" not in clean, f"Hardcoded reasoning_effort found in {py_file.name}:{idx}: {line}"

View file

@ -0,0 +1,264 @@
"""Unit and integration tests for Task A41: Clean First Install & Routing Reset.
Verifies:
1. Clean default configuration (0 profiles, 13 canonical roles with empty preferred_chain).
2. RoleRegistry CANONICAL_ROLES clean default chains.
3. Migration idempotence & preservation of user profiles without injecting stubs.
4. Action reset_router_config creates backup and resets routing without touching credentials.
5. Clean numbered slot generation upon adding accounts (codex-1, ag-1, claude-1, etc.).
6. Full compatibility with verify_multi_provider_router script.
"""
from __future__ import annotations
import json
import os
import shutil
import tempfile
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from antigravity_provider.paths import get_hermes_home, get_profile_dir
from antigravity_provider.router.action_handler import ActionExecutor, do_reset_router_config
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
get_default_router_config,
load_router_config,
save_router_config,
)
from antigravity_provider.router.settings_service import get_hub_settings, save_hub_settings
@pytest.fixture
def clean_a41_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Set up an isolated hermes home directory for testing."""
hermes_home = tmp_path / "hermes_home"
hermes_home.mkdir(parents=True, exist_ok=True)
config_file = hermes_home / "router_profiles.yaml"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(config_file))
return hermes_home, config_file
@pytest.mark.unit
def test_p0_1_clean_default_configuration():
"""P0-1 & P0-3: Clean configuration on first install has 0 profiles and 13 canonical roles with empty chains."""
# 1. Check CANONICAL_ROLES registry
assert len(CANONICAL_ROLES) == 13
for role_id, role_def in CANONICAL_ROLES.items():
assert role_def.default_preferred_chain == [], f"Role {role_id} has non-empty default chain"
# 2. Check get_default_router_config()
default_cfg = get_default_router_config()
assert len(default_cfg.profiles) == 0, f"Expected 0 profiles, got {len(default_cfg.profiles)}"
assert len(default_cfg.roles) == 13, f"Expected 13 roles, got {len(default_cfg.roles)}"
assert default_cfg.default_role == "manager"
for rname, rpol in default_cfg.roles.items():
assert rpol.preferred_chain == [], f"Expected empty preferred_chain for role {rname}"
@pytest.mark.unit
def test_p0_1_dynamic_clean_slot_numbering(clean_a41_env):
"""P0-1: Accounts added dynamically receive clean numbered slots (ag-1, codex-1, claude-1, etc.)."""
hermes_home, config_file = clean_a41_env
# 1. Antigravity slots
ag_slot_1 = AutoAssigner.find_free_slot("antigravity")
assert ag_slot_1 == "ag-1"
ProfileAuthManager.save_profile_auth("antigravity", ag_slot_1, {"tokens": {"access_token": "token1"}})
ag_slot_2 = AutoAssigner.find_free_slot("antigravity")
assert ag_slot_2 == "ag-2"
# 2. Codex slots
codex_slot_1 = AutoAssigner.find_free_slot("openai-codex")
assert codex_slot_1 == "codex-1"
ProfileAuthManager.save_profile_auth("openai-codex", codex_slot_1, {"api_key": "sk-1"})
codex_slot_2 = AutoAssigner.find_free_slot("openai-codex")
assert codex_slot_2 == "codex-2"
# 3. Claude slots
claude_slot_1 = AutoAssigner.find_free_slot("claude")
assert claude_slot_1 == "claude-1"
# 4. Grok slots
grok_slot_1 = AutoAssigner.find_free_slot("grok")
assert grok_slot_1 == "grok-1"
# 5. OpenCode slots
opengo_slot_1 = AutoAssigner.find_free_slot("opencode-go")
assert opengo_slot_1 == "opengo-1"
# 6. Local slots
local_slot_1 = AutoAssigner.find_free_slot("local")
assert local_slot_1 == "local-1"
# 7. OpenRouter, NVIDIA, Ollama slots
assert AutoAssigner.find_free_slot("openrouter") == "openrouter-1"
assert AutoAssigner.find_free_slot("nvidia") == "nvidia-1"
assert AutoAssigner.find_free_slot("ollama") == "ollama-1"
@pytest.mark.unit
def test_p0_3_migration_preserves_user_config_and_adds_missing_roles_cleanly(clean_a41_env):
"""P0-3: Migration preserves existing user profiles and chains without injecting fake profile stubs."""
hermes_home, config_file = clean_a41_env
# User configured 2 specific profiles and legacy role names
user_profiles = {
"user-primary-ag": RouterProfileConfig(
profile_id="user-primary-ag",
provider="antigravity",
account_id="user-acc-1",
preferred_models=["gemini-2.5-pro"],
),
"user-primary-codex": RouterProfileConfig(
profile_id="user-primary-codex",
provider="openai-codex",
account_id="user-acc-2",
preferred_models=["o3-mini"],
),
}
user_roles = {
"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=["user-primary-codex"]),
"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=["user-primary-ag"]),
}
user_cfg = RouterConfig(
enabled=True,
default_role="manager",
roles=user_roles,
profiles=user_profiles,
)
save_router_config(user_cfg, config_file)
# Trigger load and migration
migrated_cfg = load_router_config(config_file)
# 1. User profiles are preserved verbatim
assert len(migrated_cfg.profiles) == 2
assert "user-primary-ag" in migrated_cfg.profiles
assert "user-primary-codex" in migrated_cfg.profiles
assert migrated_cfg.profiles["user-primary-ag"].preferred_models == ["gemini-2.5-pro"]
# 2. Legacy roles migrated to canonical names preserving chains
assert "manager" in migrated_cfg.roles
assert migrated_cfg.roles["manager"].preferred_chain == ["user-primary-codex"]
assert "developer-1" in migrated_cfg.roles
assert migrated_cfg.roles["developer-1"].preferred_chain == ["user-primary-ag"]
# 3. All 13 canonical roles exist
assert len(migrated_cfg.roles) == 13
# 4. Missing roles added with clean empty chains
for rname, rpol in migrated_cfg.roles.items():
if rname not in ("manager", "developer-1"):
assert rpol.preferred_chain == [], f"Missing role {rname} should have empty chain"
@pytest.mark.unit
def test_p0_2_p0_4_reset_router_config_and_preserve_credentials(clean_a41_env):
"""P0-2 & P0-4: Reset action backs up router_profiles.yaml, resets to clean state, and strictly preserves credentials."""
hermes_home, config_file = clean_a41_env
# 1. Seed user configuration
initial_profiles = {
"ag-1": RouterProfileConfig(profile_id="ag-1", provider="antigravity"),
"codex-1": RouterProfileConfig(profile_id="codex-1", provider="openai-codex"),
}
initial_roles = {
"manager": RolePolicy(role_name="manager", preferred_chain=["codex-1"]),
"developer-1": RolePolicy(role_name="developer-1", preferred_chain=["ag-1"]),
}
save_router_config(RouterConfig(profiles=initial_profiles, roles=initial_roles), config_file)
# 2. Seed credentials across different providers and hub_settings.json
ag_auth = ProfileAuthManager.save_profile_auth("antigravity", "ag-1", {"tokens": {"access_token": "secret_ag_token"}})
codex_auth = ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-secret-codex-key"})
claude_auth = ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-secret-claude-key"})
grok_auth = ProfileAuthManager.save_profile_auth("grok", "grok-1", {"api_key": "xai-secret-grok-key"})
opengo_auth = ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "opengo-secret-key"})
# Seed hub settings
save_hub_settings({"email_masking_mode": "full", "quota_threshold_percent": 15.0})
# Verify credentials exist on disk before reset
assert ag_auth.is_file()
assert codex_auth.is_file()
assert claude_auth.is_file()
assert grok_auth.is_file()
assert opengo_auth.is_file()
# 3. Execute Reset Action via ActionExecutor
res = ActionExecutor.execute("reset_router_config", {})
assert res["ok"] is True
assert "сброшена" in res["message"].lower()
# 4. Verify backup was created
backups = list(hermes_home.glob("router_profiles.yaml.bak_*"))
assert len(backups) >= 1, "Backup file was not created during reset"
backup_content = backups[0].read_text(encoding="utf-8")
assert "codex-1" in backup_content
# 5. Verify router_profiles.yaml is now in clean state (0 profiles, 13 canonical roles with empty chains)
reloaded_cfg = load_router_config(config_file)
assert len(reloaded_cfg.profiles) == 0
assert len(reloaded_cfg.roles) == 13
for rname, rpol in reloaded_cfg.roles.items():
assert rpol.preferred_chain == []
# 6. Verify ALL credentials remain 100% intact and untouched
assert ag_auth.is_file()
assert codex_auth.is_file()
assert claude_auth.is_file()
assert grok_auth.is_file()
assert opengo_auth.is_file()
loaded_ag = ProfileAuthManager.load_profile_auth("antigravity", "ag-1")
assert loaded_ag["tokens"]["access_token"] == "secret_ag_token"
loaded_codex = ProfileAuthManager.load_profile_auth("openai-codex", "codex-1")
assert loaded_codex["api_key"] == "sk-secret-codex-key"
# Verify hub settings untouched
settings = get_hub_settings()
assert settings.get("email_masking_mode") == "full"
assert settings.get("quota_threshold_percent") == 15.0
@pytest.mark.unit
def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
"""P0-5: verify_multi_provider_router.py passes on both clean default and filled user configs."""
hermes_home, config_file = clean_a41_env
# 1. Run on clean default config (file does not exist yet)
import subprocess
import sys
env = os.environ.copy()
env["HERMES_ROUTER_CONFIG"] = str(config_file)
env["HERMES_HOME"] = str(hermes_home)
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
assert "10/10 CHECKS PASSED" in proc.stdout
# 2. Run on populated user config
AutoAssigner.find_free_slot("openai-codex")
AutoAssigner.find_free_slot("antigravity")
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
assert "10/10 CHECKS PASSED" in proc_filled.stdout

View file

@ -43,7 +43,7 @@ class TestA9ConfigMigration(unittest.TestCase):
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def test_migration_16_profiles_preserves_antigravity_and_adds_claude_grok(self):
# 1. Prepare legacy 16-profile configuration (no claude, no grok)
# 1. Prepare legacy configuration with 16 user profiles
legacy_profiles = {}
for pid in [
"codex-orch", "codex-worker-1", "codex-worker-2",
@ -60,33 +60,32 @@ class TestA9ConfigMigration(unittest.TestCase):
max_concurrency=3,
)
# Legacy role configuration missing some canonical roles
legacy_roles = {
"manager": get_default_router_config().roles["manager"],
"developer-1": get_default_router_config().roles["developer-1"],
}
legacy_cfg = RouterConfig(
enabled=True,
default_role="manager",
roles=get_default_router_config().roles,
roles=legacy_roles,
profiles=legacy_profiles,
)
save_router_config(legacy_cfg, self.config_path)
self.assertEqual(len(legacy_cfg.profiles), 16)
self.assertNotIn("grok-orch", legacy_cfg.profiles)
self.assertNotIn("claude-orch", legacy_cfg.profiles)
# 2. Load configuration (triggers migration)
# 2. Load configuration (triggers migration for missing canonical roles)
migrated_cfg = load_router_config(self.config_path)
# 3. Verify backup file created
# 3. Verify backup file created on role migration
backups = list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*"))
self.assertGreaterEqual(len(backups), 1, "Backup file was not created on migration")
# 4. Verify Claude and Grok profiles added
self.assertIn("grok-orch", migrated_cfg.profiles)
self.assertIn("grok-worker-1", migrated_cfg.profiles)
self.assertIn("grok-worker-2", migrated_cfg.profiles)
self.assertIn("claude-orch", migrated_cfg.profiles)
self.assertIn("claude-worker-1", migrated_cfg.profiles)
self.assertIn("claude-worker-2", migrated_cfg.profiles)
self.assertEqual(len(migrated_cfg.profiles), 24)
# 4. Verify user profiles are preserved untouched (16 profiles preserved, no fake profiles injected)
self.assertEqual(len(migrated_cfg.profiles), 16)
self.assertEqual(len(migrated_cfg.roles), 13)
# 5. Verify existing 10 antigravity profiles are 100% untouched
for pid in ["ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"]:
@ -96,17 +95,17 @@ class TestA9ConfigMigration(unittest.TestCase):
self.assertEqual(p.preferred_models, ["custom-model-1"], f"Models mutated for {pid}")
self.assertEqual(p.max_concurrency, 3, f"Concurrency mutated for {pid}")
# 6. Verify AutoAssigner.find_free_slot finds free slots for grok and claude
# 6. Verify AutoAssigner.find_free_slot dynamically allocates clean slots for grok and claude
with patch.dict(os.environ, {"HERMES_ROUTER_CONFIG": str(self.config_path)}):
slot_grok = AutoAssigner.find_free_slot("grok")
self.assertEqual(slot_grok, "grok-orch")
self.assertEqual(slot_grok, "grok-1")
slot_claude = AutoAssigner.find_free_slot("claude")
self.assertEqual(slot_claude, "claude-orch")
self.assertEqual(slot_claude, "claude-1")
# 7. Verify Idempotence: subsequent loads do not create extra backups
backup_count_before = len(backups)
reload_cfg = load_router_config(self.config_path)
self.assertEqual(len(reload_cfg.profiles), 24)
self.assertEqual(len(reload_cfg.profiles), 18) # 16 + grok-1 + claude-1 registered during find_free_slot
backup_count_after = len(list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*")))
self.assertEqual(backup_count_before, backup_count_after)

View file

@ -53,10 +53,10 @@ def setup_test_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.unit
def test_unlimited_provider_slots_p0_1(tmp_path: Path):
"""P0-1: Connecting 4th and 5th account of a provider removes slot ceiling and dynamically registers profiles."""
# Pre-authenticate default 3 codex slots: codex-orch, codex-worker-1, codex-worker-2
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-1"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {"api_key": "sk-2"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", {"api_key": "sk-3"})
# Pre-authenticate 3 codex slots: codex-1, codex-2, codex-3
ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-1"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-2", {"api_key": "sk-2"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-3", {"api_key": "sk-3"})
# 4th slot should be generated dynamically (e.g. codex-4)
slot_4 = AutoAssigner.find_free_slot("openai-codex")

View file

@ -195,7 +195,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
# Initial free slot
first_slot = AutoAssigner.find_free_slot("antigravity")
assert first_slot == "ag-orch-fallback"
assert first_slot == "ag-1"
# Connect first slot
AutoAssigner.ensure_profile_definition("antigravity", first_slot)
@ -204,7 +204,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
# Next free slot
second_slot = AutoAssigner.find_free_slot("antigravity")
assert second_slot == "ag-w1"
assert second_slot == "ag-2"
# Connect second slot
AutoAssigner.ensure_profile_definition("antigravity", second_slot)
@ -213,7 +213,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
# Next free slot
third_slot = AutoAssigner.find_free_slot("antigravity")
assert third_slot == "ag-w2"
assert third_slot == "ag-3"
# ── TEST 5: Model selection and assignment (P1-6) ──

View file

@ -0,0 +1,389 @@
"""A32 Provider Adapters & Limits Export Test Suite.
Verifies:
1. Ollama adapter (local and remote endpoints, API key, discover_models via /models and /api/tags,
health_check, unlimited quota).
2. Claude adapter (health_check real probe, discover_models real probe with fallback).
3. OpenRouter adapter (HTTP-Referer, X-OpenRouter-Title headers, context_length and display_name discovery).
4. NVIDIA adapter (invoke, health_check, discover_models).
5. Quota & Limits export (/api/quotas/export endpoint and export_quotas action in JSON and CSV).
6. Multi-profile credential and token isolation.
"""
from __future__ import annotations
import csv
import io
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.adapters.claude_adapter import DEFAULT_CLAUDE_MODELS, ClaudeAdapter
from antigravity_provider.router.adapters.ollama_adapter import DEFAULT_OLLAMA_BASE_URL, OllamaAdapter
from antigravity_provider.router.adapters.openrouter_adapter import OpenRouterAdapter
from antigravity_provider.router.adapters.nvidia_adapter import NvidiaAdapter
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.action_handler import ActionExecutor, generate_quotas_export
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.web.server import app
def _mock_urlopen(payload: dict, status: int = 200) -> MagicMock:
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.status = status
mock_response.__enter__.return_value = mock_response
return mock_response
def _chat_response(content: str = "OK") -> dict:
return {
"id": "chatcmpl-test",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
}
# ─────────────────────────────────────────────────────────────────────────────
# 1. Ollama Adapter Tests
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_ollama_adapter_registered():
adapter = get_adapter("ollama")
assert adapter is not None
assert isinstance(adapter, OllamaAdapter)
@pytest.mark.unit
def test_ollama_invoke_default_base_url():
adapter = OllamaAdapter()
profile = RouterProfileConfig(
profile_id="ollama-1",
provider="ollama",
preferred_models=["llama3:latest"],
auth_config={},
)
request = {
"model": "llama3:latest",
"messages": [{"role": "user", "content": "ping"}],
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response("pong"))) as mock_urlopen:
resp = adapter.invoke(profile, request)
assert resp["choices"][0]["message"]["content"] == "pong"
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/chat/completions"
assert req_arg.get_method() == "POST"
assert "Authorization" not in req_arg.headers
@pytest.mark.unit
def test_ollama_invoke_remote_custom_url_and_api_key():
adapter = OllamaAdapter()
profile = RouterProfileConfig(
profile_id="ollama-remote-1",
provider="ollama",
custom_base_url="https://remote-ollama.example.com:11434/v1",
preferred_models=["mistral:latest"],
auth_config={"api_key": "secret-bearer-token"},
)
request = {
"model": "mistral:latest",
"messages": [{"role": "user", "content": "hi"}],
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response("hello"))) as mock_urlopen:
resp = adapter.invoke(profile, request)
assert resp["choices"][0]["message"]["content"] == "hello"
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == "https://remote-ollama.example.com:11434/v1/chat/completions"
assert req_arg.headers.get("Authorization") == "Bearer secret-bearer-token"
@pytest.mark.unit
def test_ollama_discover_models_v1_models_endpoint():
adapter = OllamaAdapter()
profile = RouterProfileConfig(
profile_id="ollama-1",
provider="ollama",
auth_config={},
)
models_payload = {"data": [{"id": "qwen2.5-coder:7b"}, {"id": "llama3.1:8b"}]}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(models_payload)) as mock_urlopen:
models = adapter.discover_models(profile)
assert models == ["llama3.1:8b", "qwen2.5-coder:7b"]
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == "http://127.0.0.1:11434/v1/models"
@pytest.mark.unit
def test_ollama_discover_models_fallback_to_native_api_tags():
adapter = OllamaAdapter()
profile = RouterProfileConfig(
profile_id="ollama-1",
provider="ollama",
auth_config={},
)
native_tags_payload = {
"models": [
{"name": "deepseek-r1:14b", "model": "deepseek-r1:14b"},
{"name": "phi4:latest", "model": "phi4:latest"},
]
}
def _side_effect(req, timeout=5):
if req.get_full_url().endswith("/v1/models"):
raise OSError("404 Not Found")
if req.get_full_url().endswith("/api/tags"):
return _mock_urlopen(native_tags_payload)
raise OSError("Unknown url")
with patch("urllib.request.urlopen", side_effect=_side_effect):
models = adapter.discover_models(profile)
assert models == ["deepseek-r1:14b", "phi4:latest"]
@pytest.mark.unit
def test_ollama_health_check():
adapter = OllamaAdapter()
profile = RouterProfileConfig(
profile_id="ollama-1",
provider="ollama",
auth_config={},
)
with patch("urllib.request.urlopen", return_value=_mock_urlopen({"data": []}, status=200)):
assert adapter.health_check(profile) is True
with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
assert adapter.health_check(profile) is False
@pytest.mark.unit
def test_ollama_quota_unlimited():
service = AccountQuotaService.get()
snap = service.get_snapshot("ollama", "ollama-1")
assert snap is not None
assert snap.buckets[0].status == "unlimited"
assert snap.buckets[0].remaining_percent == 100.0
assert "Без ограничений" in snap.buckets[0].formatted_remaining()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Claude Adapter Tests (Real Probes & Fallback)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_claude_health_check_real_probe():
adapter = ClaudeAdapter()
profile = RouterProfileConfig(
profile_id="claude-1",
provider="claude",
auth_config={"api_key": "sk-ant-testkey"},
)
with patch("urllib.request.urlopen", return_value=_mock_urlopen({"data": []}, status=200)) as mock_urlopen:
ok = adapter.health_check(profile)
assert ok is True
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == "https://api.anthropic.com/v1/models"
assert req_arg.headers.get("X-api-key") == "sk-ant-testkey"
assert req_arg.headers.get("Anthropic-version") == "2023-06-01"
@pytest.mark.unit
def test_claude_discover_models_api_probe_and_fallback():
adapter = ClaudeAdapter()
profile = RouterProfileConfig(
profile_id="claude-1",
provider="claude",
auth_config={"access_token": "oauth-token-123"},
)
models_payload = {
"data": [
{"id": "claude-3-7-sonnet-20250219", "display_name": "Claude 3.7 Sonnet"},
{"id": "claude-3-5-haiku-20241022", "display_name": "Claude 3.5 Haiku"},
]
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(models_payload)) as mock_urlopen:
models = adapter.discover_models(profile)
assert models == ["claude-3-5-haiku-20241022", "claude-3-7-sonnet-20250219"]
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.headers.get("Authorization") == "Bearer oauth-token-123"
assert req_arg.headers.get("Anthropic-beta") == "oauth-2025-04-20"
# Fallback test on error
with patch("urllib.request.urlopen", side_effect=OSError("API offline")):
fallback_models = adapter.discover_models(profile)
assert fallback_models == DEFAULT_CLAUDE_MODELS
# ─────────────────────────────────────────────────────────────────────────────
# 3. OpenRouter Adapter Tests (Referer, Title, Context Length, Display Name)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_openrouter_headers_and_metadata_discovery():
adapter = OpenRouterAdapter()
profile = RouterProfileConfig(
profile_id="openrouter-1",
provider="openrouter",
auth_config={"api_key": "sk-or-v1-key"},
)
models_payload = {
"data": [
{
"id": "openai/gpt-4o",
"name": "OpenAI: GPT-4o",
"context_length": 128000,
},
{
"id": "anthropic/claude-3.5-sonnet",
"name": "Anthropic: Claude 3.5 Sonnet",
"context_length": 200000,
},
]
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(models_payload)) as mock_urlopen:
models = adapter.discover_models(profile)
assert "openai/gpt-4o" in models
assert "anthropic/claude-3.5-sonnet" in models
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.headers.get("Http-referer") == "https://github.com/ochenstarik-ui/hermes-hub"
assert req_arg.headers.get("X-openrouter-title") == "Hermes Hub"
# Check extracted metadata & context window
gpt4o_meta = adapter.get_model_metadata("openai/gpt-4o")
assert gpt4o_meta is not None
assert gpt4o_meta["display_name"] == "OpenAI: GPT-4o"
assert gpt4o_meta["context_length"] == 128000
assert adapter.get_context_window(profile, "openai/gpt-4o") == 128000
# ─────────────────────────────────────────────────────────────────────────────
# 4. NVIDIA Adapter Tests
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_nvidia_adapter_invoke_and_health():
adapter = get_adapter("nvidia")
assert isinstance(adapter, NvidiaAdapter)
profile = RouterProfileConfig(
profile_id="nvidia-1",
provider="nvidia",
auth_config={"api_key": "nvapi-test"},
)
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response("NV_OK"))):
resp = adapter.invoke(profile, {"model": "meta/llama-3.1-8b-instruct", "messages": [{"role": "user", "content": "hi"}]})
assert resp["choices"][0]["message"]["content"] == "NV_OK"
with patch("urllib.request.urlopen", return_value=_mock_urlopen({}, status=200)):
assert adapter.health_check(profile) is True
# ─────────────────────────────────────────────────────────────────────────────
# 5. Quota & Limits Export Tests
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_generate_quotas_export_json_and_csv():
json_export = generate_quotas_export(format="json")
assert isinstance(json_export, dict)
assert "exported_at" in json_export
assert "total_profiles" in json_export
assert "profiles" in json_export
assert "rows" in json_export
assert isinstance(json_export["profiles"], list)
csv_export = generate_quotas_export(format="csv")
assert isinstance(csv_export, str)
reader = csv.reader(io.StringIO(csv_export))
header = next(reader)
assert "provider" in header
assert "profile_id" in header
assert "remaining_percent" in header
assert "status" in header
@pytest.mark.unit
def test_action_handler_export_quotas():
res_json = ActionExecutor.execute("export_quotas", {"format": "json"})
assert res_json["ok"] is True
assert res_json["data"]["format"] == "json"
assert "report" in res_json["data"]
res_csv = ActionExecutor.execute("export_quotas", {"format": "csv"})
assert res_csv["ok"] is True
assert res_csv["data"]["format"] == "csv"
assert "content" in res_csv["data"]
@pytest.mark.unit
def test_api_quotas_export_endpoint():
client = TestClient(app)
# JSON export
resp_json = client.get("/api/quotas/export?format=json")
assert resp_json.status_code == 200
data = resp_json.json()
assert "exported_at" in data
assert "profiles" in data
# CSV export
resp_csv = client.get("/api/quotas/export?format=csv")
assert resp_csv.status_code == 200
assert "text/csv" in resp_csv.headers["content-type"]
assert "hermes_quotas_export.csv" in resp_csv.headers["content-disposition"]
assert "provider,profile_id" in resp_csv.text
# ─────────────────────────────────────────────────────────────────────────────
# 6. Multi-Profile Key Isolation Tests
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_profile_credential_isolation(tmp_path):
with patch("antigravity_provider.paths.get_hermes_home", return_value=tmp_path):
ProfileAuthManager.save_profile_auth("ollama", "ollama-1", {"api_key": "key-ollama-1", "base_url": "http://127.0.0.1:11434/v1"})
ProfileAuthManager.save_profile_auth("ollama", "ollama-2", {"api_key": "key-ollama-2", "base_url": "http://192.168.1.50:11434/v1"})
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-1", {"api_key": "key-or-1"})
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-2", {"api_key": "key-or-2"})
p1 = ProfileAuthManager.load_profile_auth("ollama", "ollama-1")
p2 = ProfileAuthManager.load_profile_auth("ollama", "ollama-2")
or1 = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
or2 = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-2")
assert p1["api_key"] == "key-ollama-1"
assert p2["api_key"] == "key-ollama-2"
assert p1["base_url"] != p2["base_url"]
assert or1["api_key"] == "key-or-1"
assert or2["api_key"] == "key-or-2"
assert or1["api_key"] != or2["api_key"]

View file

@ -36,11 +36,6 @@ from antigravity_provider.router.codex_oauth import (
get_codex_oauth_session,
cancel_codex_oauth_session,
)
# Pulls customtkinter transitively; without this guard a headless run aborts
# collection of the entire session instead of skipping this module.
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui.components import enable_clipboard_shortcuts, HubEntry
@pytest.fixture(autouse=True)
@ -277,23 +272,7 @@ def test_g_extract_jwt_identity():
assert sub2 == "auth0|openai-456"
# ==============================================================================
# TEST H: HubEntry and enable_clipboard_shortcuts
# ==============================================================================
def test_h_enable_clipboard_shortcuts_binding():
"""Test that enable_clipboard_shortcuts attaches paste/copy/cut/select-all handlers without error."""
mock_entry = MagicMock()
mock_entry._entry = MagicMock()
mock_entry.clipboard_get.return_value = "pasted_text_123"
enable_clipboard_shortcuts(mock_entry)
# Check bind was called for multiple standard and Cyrillic keys
bound_events = [c[0][0] for c in mock_entry._entry.bind.call_args_list]
assert "<Control-v>" in bound_events
assert "<Control-a>" in bound_events
assert "<Shift-Insert>" in bound_events
assert "<Control-cyrillic_em>" in bound_events
# ==============================================================================

View file

@ -78,28 +78,26 @@ def test_auto_assigner_find_free_slot_for_all_five_providers(clean_env):
for provider in ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]:
slot = AutoAssigner.find_free_slot(provider)
if slot is not None:
# Slot MUST exist in config.profiles
config = load_router_config()
assert slot in config.profiles
assert config.profiles[slot].provider == provider
# Test allocation when all default slots are filled (A26: unlimited accounts)
# Test allocation with clean slot numbering (A41 / A26 unlimited accounts)
slot_claude = AutoAssigner.find_free_slot("claude")
assert slot_claude is not None
assert slot_claude in ("claude-orch", "claude-worker-1", "claude-worker-2")
assert slot_claude == "claude-1"
# Simulate fake auth on all 3 default claude slots
for c_slot in ["claude-orch", "claude-worker-1", "claude-worker-2"]:
ProfileAuthManager.save_profile_auth("claude", c_slot, {"api_key": "sk-ant-test-key-1234567890123456"})
# Simulate fake auth on claude-1
ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-test-key-1234567890123456"})
# Now all default claude slots are taken -> find_free_slot dynamically generates claude-worker-3
# Next slot dynamically allocated is claude-2
next_slot = AutoAssigner.find_free_slot("claude")
assert next_slot == "claude-worker-3"
assert next_slot == "claude-2"
cfg = load_router_config()
assert "claude-worker-3" in cfg.profiles
assert cfg.profiles["claude-worker-3"].provider == "claude"
assert "claude-2" in cfg.profiles
assert cfg.profiles["claude-2"].provider == "claude"
rec_slot, title, reason = AutoAssigner.recommend_assignment("claude")
assert rec_slot == "claude-worker-3"
assert rec_slot == "claude-2"
@pytest.mark.unit
@ -129,11 +127,10 @@ def test_adapter_no_browser_on_expired_token(clean_env, monkeypatch):
assert "Авторизация истекла" in str(exc_info.value)
@pytest.mark.ui
@pytest.mark.unit
def test_do_test_profile_no_browser_on_expired_token(clean_env):
"""P0-3: Verify do_test_profile in UI layer catches expired token without invoking adapter."""
pytest.importorskip("customtkinter")
from antigravity_provider.router.hermes_hub_app import do_test_profile
"""P0-3: Verify do_test_profile catches expired token without invoking adapter."""
from antigravity_provider.router.action_handler import do_test_profile
expired_auth = {
"provider": "antigravity",

View file

@ -82,14 +82,8 @@ def test_module_is_importable(module_name: str) -> None:
@pytest.mark.unit
def test_gui_test_modules_guard_optional_ui_dependency() -> None:
"""Test modules touching customtkinter must call pytest.importorskip.
Without the guard a headless environment aborts collection of the whole
session ("Interrupted: 1 error during collection") instead of skipping the
affected module, which takes the release gate down with it.
"""
# Modules that pull the GUI toolkit in transitively when imported.
def test_no_tests_import_removed_desktop_ui() -> None:
"""Verify that no test modules import the removed CustomTkinter desktop UI."""
GUI_BEARING_PREFIXES = (
"customtkinter",
"antigravity_provider.router.ui",
@ -113,17 +107,10 @@ def test_gui_test_modules_guard_optional_ui_dependency() -> None:
tree = ast.parse(text)
except SyntaxError:
continue
# A mention in prose is not an import; only real imports need the guard.
if not _imports_gui(tree):
continue
if "importorskip" not in text:
if _imports_gui(tree):
offenders.append(path.name)
assert not offenders, (
"test modules import customtkinter without pytest.importorskip: "
+ ", ".join(offenders)
+ " — add pytest.importorskip('customtkinter') above the import"
)
assert not offenders, f"The following test files still import removed desktop UI: {offenders}"
@pytest.mark.unit

View file

@ -67,14 +67,14 @@ def test_windows_launcher_browser_search_and_health_check():
assert "HermesHubWeb" in web_cs or "WebLauncher" in web_cs
def test_windows_installer_creates_both_shortcuts():
"""Verify HermesHubSetup.cs creates both Web and Desktop shortcuts."""
def test_windows_installer_creates_standard_shortcut_and_cleans_legacy():
"""Verify HermesHubSetup.cs creates standard Hermes Hub.lnk shortcut and cleans up legacy shortcuts."""
setup_cs = (INSTALLER_DIR / "HermesHubSetup.cs").read_text(encoding="utf-8")
assert "Hermes Hub.lnk" in setup_cs
assert "Hermes Hub (Web).lnk" in setup_cs
assert "Hermes Hub (Desktop).lnk" in setup_cs
assert "HermesHubWeb.exe" in setup_cs
assert "HermesHub.exe" in setup_cs
def test_linux_installer_script_structure():

View file

@ -247,46 +247,45 @@ class TestLocalLLMModelDiscovery:
class TestLocalLLMConfigAndAutoAssigner:
"""Test default configuration, migration, and slot auto-assignment."""
def test_default_config_contains_local_slots(self):
def test_default_config_clean_roles_and_local_registration(self):
cfg = get_default_router_config()
assert "local-1" in cfg.profiles
assert "local-2" in cfg.profiles
assert len(cfg.profiles) == 0
assert len(cfg.roles) == 13
p1 = cfg.profiles["local-1"]
slot = AutoAssigner.find_free_slot("local")
assert slot == "local-1"
reloaded = load_router_config()
assert "local-1" in reloaded.profiles
p1 = reloaded.profiles["local-1"]
assert p1.provider == "local"
assert "code-reviewer" in p1.capabilities
assert "coding" in p1.capabilities
assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models
assert p1.max_concurrency == 1
p2 = cfg.profiles["local-2"]
assert p2.provider == "local"
assert "tester" in p2.capabilities
assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models
assert p2.max_concurrency == 1
def test_config_migration_adds_local_slots(self):
def test_config_migration_preserves_user_profiles_without_injecting_stubs(self):
tmp_dir = tempfile.mkdtemp()
try:
config_path = Path(tmp_dir) / "router_profiles.yaml"
legacy_profiles = {
"codex-orch": RouterProfileConfig(
profile_id="codex-orch",
"custom-codex": RouterProfileConfig(
profile_id="custom-codex",
provider="openai-codex",
)
}
legacy_cfg = RouterConfig(
enabled=True,
default_role="manager",
roles=get_default_router_config().roles,
roles={"manager": get_default_router_config().roles["manager"]},
profiles=legacy_profiles,
)
save_router_config(legacy_cfg, config_path)
migrated = load_router_config(config_path)
assert "local-1" in migrated.profiles
assert "local-2" in migrated.profiles
assert migrated.profiles["local-1"].provider == "local"
# User profile is preserved
assert "custom-codex" in migrated.profiles
# 13 canonical roles are migrated
assert len(migrated.roles) == 13
# No dummy local profiles injected
assert "local-1" not in migrated.profiles
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)

View file

@ -11,6 +11,7 @@ from pathlib import Path
import pytest
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.router_config import load_router_config, save_router_config
@ -45,8 +46,9 @@ def temp_models_cache(tmp_path, monkeypatch):
def test_set_model_success_and_config_persistence(temp_models_cache):
"""Verify setting valid discovered model succeeds and persists in router_config."""
svc, _ = temp_models_cache
config = load_router_config()
profile_id = "ag-w1"
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
config = load_router_config()
assert profile_id in config.profiles
# Pick valid discovered model
@ -69,8 +71,9 @@ def test_set_model_success_and_config_persistence(temp_models_cache):
def test_set_model_rejects_nonexistent_model(temp_models_cache):
"""Verify nonexistent model is strictly rejected without modifying configuration."""
svc, _ = temp_models_cache
config = load_router_config()
profile_id = "ag-w1"
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
config = load_router_config()
orig_models = list(config.profiles[profile_id].preferred_models)
# Attempt to set invalid/hallucinated model

View file

@ -8,8 +8,6 @@ from unittest.mock import MagicMock, patch
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.router_config import (
@ -18,7 +16,6 @@ from antigravity_provider.router.router_config import (
RouterProfileConfig,
save_router_config,
)
from antigravity_provider.router.ui.model_catalog import CachedModels, refresh_models_async
@pytest.fixture
@ -138,25 +135,4 @@ def test_model_discovery_cache_retained_on_timeout_and_error(isolated_hub):
assert service.get_models("antigravity") == ["existing-gemini-model"]
def test_ui_model_catalog_refresh_models_async(isolated_hub):
"""model_catalog.refresh_models_async dispatches and completes via service."""
service = ModelDiscoveryService.get()
with service._cache_lock:
service._cache["antigravity"] = {
"models": ["gemini-2.5-pro"],
"discovered_at": 1000.0,
}
completed: list[CachedModels] = []
done_evt = threading.Event()
def _on_done(cm: CachedModels) -> None:
completed.append(cm)
done_evt.set()
with patch.object(service, "_probe_provider", return_value=["gemini-2.5-pro", "gemini-2.5-flash"]):
ok = refresh_models_async("antigravity", _on_done)
assert ok is True
assert done_evt.wait(timeout=3.0) is True
assert len(completed) == 1
assert "gemini-2.5-pro" in completed[0].models

View file

@ -46,57 +46,33 @@ from antigravity_provider.router.cli_commands import (
class TestRouterConfig:
"""Test configuration schema, profile loading, and role definitions."""
"""Test configuration schema, clean default config, and role definitions."""
def test_default_config_has_24_profiles(self):
def test_default_config_is_clean(self):
config = get_default_router_config()
assert len(config.profiles) == 24
# 3 Codex
assert "codex-orch" in config.profiles
assert "codex-worker-1" in config.profiles
assert "codex-worker-2" in config.profiles
# 10 Antigravity (7 active, 3 cold)
assert "ag-orch-fallback" in config.profiles
assert "ag-w1" in config.profiles
assert "ag-w4" in config.profiles
assert "ag-spare-1" in config.profiles
assert "ag-cold-1" in config.profiles
assert config.profiles["ag-cold-1"].enabled is False
# 3 OpenCode Go
assert "opengo-1" in config.profiles
assert "opengo-2" in config.profiles
assert "opengo-3" in config.profiles
# 3 Claude
assert "claude-orch" in config.profiles
assert "claude-worker-1" in config.profiles
assert "claude-worker-2" in config.profiles
# 3 Grok
assert "grok-orch" in config.profiles
assert "grok-worker-1" in config.profiles
assert "grok-worker-2" in config.profiles
assert len(config.profiles) == 0
assert len(config.roles) == 13
assert config.default_role == "manager"
assert config.enabled is True
def test_role_policies_chains(self):
config = get_default_router_config()
assert "manager" in config.roles
orch = config.roles["manager"]
assert "codex-orch" in orch.preferred_chain
assert "ag-orch-fallback" in orch.preferred_chain
assert "opengo-3" in orch.preferred_chain
assert "developer-1" in config.roles
assert "developer-2" in config.roles
assert "code-reviewer" in config.roles
assert "researcher" in config.roles
assert "tester" in config.roles
assert "tech-writer" in config.roles
assert "analyst" in config.roles
assert "guardian" in config.roles
assert "cost-controller" in config.roles
assert "integration-expert" in config.roles
assert "security-expert" in config.roles
assert "dependency-agent" in config.roles
coder = config.roles["developer-1"]
assert "codex-worker-1" in coder.preferred_chain
assert "ag-w1" in coder.preferred_chain
assert "opengo-1" in coder.preferred_chain
reviewer = config.roles["code-reviewer"]
assert "codex-worker-2" in reviewer.preferred_chain
assert "opengo-2" in reviewer.preferred_chain
assert "ag-w2" in reviewer.preferred_chain
research = config.roles["researcher"]
assert "opengo-1" in research.preferred_chain
assert "ag-w3" in research.preferred_chain
assert "opengo-2" in research.preferred_chain
for rname, rpol in config.roles.items():
assert rpol.preferred_chain == [], f"Expected empty chain for {rname} on clean install"
class TestHealthTracker:
@ -175,10 +151,30 @@ class TestSessionAffinityAndLeases:
class TestRouterEngineFailover:
"""Test multi-provider role-aware failover execution loop."""
@staticmethod
def _get_test_config() -> RouterConfig:
return RouterConfig(
enabled=True,
default_role="manager",
roles={
"manager": RolePolicy(
role_name="manager",
preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
max_failover_attempts=3,
session_affinity_enabled=True,
)
},
profiles={
"codex-orch": RouterProfileConfig(profile_id="codex-orch", provider="openai-codex"),
"ag-orch-fallback": RouterProfileConfig(profile_id="ag-orch-fallback", provider="antigravity"),
"opengo-3": RouterProfileConfig(profile_id="opengo-3", provider="opencode-go"),
},
)
def test_orchestrator_failover_chain(self, tmp_path):
state_file = tmp_path / "test_router_state.json"
engine = RouterEngine(
config=get_default_router_config(),
config=self._get_test_config(),
health=HealthTracker(state_file=state_file),
affinity=SessionAffinityTracker(),
)
@ -217,7 +213,7 @@ class TestRouterEngineFailover:
def test_session_affinity_retention_after_failover(self, tmp_path):
state_file = tmp_path / "test_affinity_state.json"
engine = RouterEngine(
config=get_default_router_config(),
config=self._get_test_config(),
health=HealthTracker(state_file=state_file),
affinity=SessionAffinityTracker(),
)

View file

@ -162,87 +162,7 @@ def test_d_oauth_error_callback(tmp_path, monkeypatch):
assert mock_exchange.call_count == 0
@pytest.mark.unit
def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root):
"""TEST E: Repeated 'Открыть в браузере' does NOT change session, state, verifier, or URL."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
root = ctk.CTkToplevel(tk_root)
root.withdraw()
fake_session = MagicMock()
fake_session.status = "pending"
fake_session.is_dev_mode = False
fake_session.error_msg = None
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
try:
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
wizard = AddAccountWizard(root)
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
wizard._polling_active = False
orig_session_id = wizard.grok_session_id
orig_url = wizard.grok_url
session = get_grok_oauth_session(orig_session_id)
with patch("webbrowser.open") as mock_open:
wizard._open_grok_browser()
wizard._open_grok_browser()
wizard._open_grok_browser()
assert mock_open.call_count == 3
for call in mock_open.call_args_list:
assert call[0][0] == orig_url
assert wizard.grok_session_id == orig_session_id
assert wizard.grok_url == orig_url
wizard.destroy()
finally:
root.destroy()
@pytest.mark.unit
def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root):
"""TEST F: Copy button works immediately upon entering Step 2 without opening browser."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
root = ctk.CTkToplevel(tk_root)
root.withdraw()
fake_session = MagicMock()
fake_session.status = "pending"
fake_session.is_dev_mode = False
fake_session.error_msg = None
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
try:
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
wizard = AddAccountWizard(root)
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
wizard._polling_active = False
assert wizard.grok_url is not None
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
wizard._copy_grok_url()
clipboard_content = wizard.clipboard_get()
assert clipboard_content == wizard.grok_url
wizard.destroy()
finally:
root.destroy()
@pytest.mark.unit

View file

@ -0,0 +1,267 @@
"""A34 P0-2a: OpenRouter + NVIDIA OpenAI-compatible adapters.
TDD tests (RED first, then GREEN):
1. get_adapter registration for openrouter / nvidia provider keys.
2. invoke() POSTs to the configurable base_url (profile.custom_base_url,
then auth_config["base_url"], then provider default).
3. discover_models() does GET {base_url}/models and returns the server's
model list no invented/hardcoded model list.
4. Bearer auth header from auth_config api_key.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.router_config import RouterProfileConfig
OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
NVIDIA_DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
def _mock_urlopen(payload: dict) -> MagicMock:
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.__enter__.return_value = mock_response
return mock_response
def _chat_response(content: str = "OK") -> dict:
return {
"id": "chatcmpl-test",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
}
def _models_response(ids: list[str]) -> dict:
return {"data": [{"id": m, "object": "model"} for m in ids]}
# ─────────────────────────────────────────────────────────────────────────────
# 1. Registration
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_get_adapter_openrouter_registered():
adapter = get_adapter("openrouter")
assert adapter is not None
assert type(adapter).__name__ == "OpenRouterAdapter"
@pytest.mark.unit
def test_get_adapter_nvidia_registered():
adapter = get_adapter("nvidia")
assert adapter is not None
assert type(adapter).__name__ == "NvidiaAdapter"
@pytest.mark.unit
def test_get_adapter_aliases_registered():
assert type(get_adapter("openrouter")).__name__ == "OpenRouterAdapter"
assert type(get_adapter("nvidia")).__name__ == "NvidiaAdapter"
assert type(get_adapter("nvidia-nim")).__name__ == "NvidiaAdapter"
# ─────────────────────────────────────────────────────────────────────────────
# 2. invoke() hits the configurable URL
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
def test_invoke_uses_profile_custom_base_url(provider):
adapter = get_adapter(provider)
profile = RouterProfileConfig(
profile_id=f"{provider}-1",
provider=provider,
custom_base_url="http://127.0.0.1:9999/v1",
preferred_models=["some-model"],
auth_config={"api_key": "test-key"},
)
request = {
"model": "some-model",
"messages": [{"role": "user", "content": "Hello"}],
"temperature": 0.5,
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
resp = adapter.invoke(profile, request)
assert resp["choices"][0]["message"]["content"] == "OK"
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == "http://127.0.0.1:9999/v1/chat/completions"
assert req_arg.get_method() == "POST"
assert req_arg.headers.get("Authorization") == "Bearer test-key"
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["model"] == "some-model"
assert payload["messages"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
def test_invoke_uses_auth_config_base_url(provider):
adapter = get_adapter(provider)
profile = RouterProfileConfig(
profile_id=f"{provider}-2",
provider=provider,
preferred_models=["some-model"],
auth_config={"base_url": "http://127.0.0.1:9998/v1", "api_key": "k2"},
)
request = {
"model": "some-model",
"messages": [{"role": "user", "content": "Hi"}],
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == "http://127.0.0.1:9998/v1/chat/completions"
@pytest.mark.unit
def test_invoke_openrouter_default_base_url():
adapter = get_adapter("openrouter")
profile = RouterProfileConfig(
profile_id="openrouter-1",
provider="openrouter",
preferred_models=["openai/gpt-4o"],
auth_config={"api_key": "or-key"},
)
request = {
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "Hi"}],
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == f"{OPENROUTER_DEFAULT_BASE_URL}/chat/completions"
@pytest.mark.unit
def test_invoke_nvidia_default_base_url():
adapter = get_adapter("nvidia")
profile = RouterProfileConfig(
profile_id="nvidia-1",
provider="nvidia",
preferred_models=["meta/llama-3.1-8b-instruct"],
auth_config={"api_key": "nv-key"},
)
request = {
"model": "meta/llama-3.1-8b-instruct",
"messages": [{"role": "user", "content": "Hi"}],
}
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == f"{NVIDIA_DEFAULT_BASE_URL}/chat/completions"
# ─────────────────────────────────────────────────────────────────────────────
# 3. discover_models() — GET /models, no invented list
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
def test_discover_models_gets_models_endpoint(provider):
adapter = get_adapter(provider)
profile = RouterProfileConfig(
profile_id=f"{provider}-1",
provider=provider,
custom_base_url="http://127.0.0.1:9997/v1",
auth_config={"api_key": "k"},
)
server_models = ["model-a", "model-b", "model-c"]
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_models_response(server_models))) as mock_urlopen:
models = adapter.discover_models(profile)
req_arg = mock_urlopen.call_args[0][0]
assert req_arg.get_full_url() == "http://127.0.0.1:9997/v1/models"
assert req_arg.get_method() == "GET"
assert models == sorted(server_models)
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
def test_discover_models_returns_server_list_not_invented(provider):
"""The returned list must come from the server response, not a hardcoded list."""
adapter = get_adapter(provider)
profile = RouterProfileConfig(
profile_id=f"{provider}-1",
provider=provider,
custom_base_url="http://127.0.0.1:9996/v1",
auth_config={"api_key": "k"},
)
server_models = ["totally-custom-model-xyz"]
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_models_response(server_models))):
models = adapter.discover_models(profile)
assert models == ["totally-custom-model-xyz"]
# No invented defaults: a model that the server did not list must not appear.
assert "deepseek-chat" not in models
assert "gpt-4o" not in models
assert "llama" not in " ".join(models).lower()
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
def test_discover_models_fallback_on_error(provider):
"""On transport error, fall back to profile.preferred_models (no invented list)."""
adapter = get_adapter(provider)
profile = RouterProfileConfig(
profile_id=f"{provider}-1",
provider=provider,
custom_base_url="http://127.0.0.1:9995/v1",
preferred_models=["my-pref-model"],
auth_config={"api_key": "k"},
)
with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
models = adapter.discover_models(profile)
assert models == ["my-pref-model"]
# ─────────────────────────────────────────────────────────────────────────────
# 4. health_check
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
def test_health_check(provider):
adapter = get_adapter(provider)
profile = RouterProfileConfig(
profile_id=f"{provider}-1",
provider=provider,
custom_base_url="http://127.0.0.1:9994/v1",
auth_config={"api_key": "k"},
)
ok_resp = MagicMock()
ok_resp.status = 200
ok_resp.__enter__.return_value = ok_resp
with patch("urllib.request.urlopen", return_value=ok_resp):
assert adapter.health_check(profile) is True
import urllib.error
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("refused")):
assert adapter.health_check(profile) is False

View file

@ -0,0 +1,387 @@
import re
import subprocess
from pathlib import Path
import pytest
APP_JS_PATH = Path("src/antigravity_provider/router/web/static/app.js")
INDEX_HTML_PATH = Path("src/antigravity_provider/router/web/static/index.html")
@pytest.fixture
def app_js_content():
assert APP_JS_PATH.exists(), f"{APP_JS_PATH} does not exist"
return APP_JS_PATH.read_text(encoding="utf-8")
@pytest.fixture
def index_html_content():
assert INDEX_HTML_PATH.exists(), f"{INDEX_HTML_PATH} does not exist"
return INDEX_HTML_PATH.read_text(encoding="utf-8")
def _is_function_defined(code: str, func_name: str) -> bool:
patterns = [
rf"function\s+{re.escape(func_name)}\s*\(",
rf"async\s+function\s+{re.escape(func_name)}\s*\(",
rf"(?:const|let|var)\s+{re.escape(func_name)}\s*=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z0-9_$]+)\s*=>",
rf"(?:const|let|var)\s+{re.escape(func_name)}\s*=\s*(?:async\s*)?function\s*\(",
]
return any(re.search(p, code) for p in patterns)
@pytest.mark.unit
def test_p0_1_open_add_account_wizard_defined(app_js_content):
"""P0-1: openAddAccountWizard must be defined in app.js."""
assert _is_function_defined(app_js_content, "openAddAccountWizard"), (
"openAddAccountWizard is called in DOM/events but has 0 definitions in app.js"
)
@pytest.mark.unit
def test_p0_1_handle_node_account_change_defined(app_js_content):
"""P0-1: handleNodeAccountChange must be defined in app.js."""
assert _is_function_defined(app_js_content, "handleNodeAccountChange"), (
"handleNodeAccountChange is called in overview/routing diagrams but has 0 definitions in app.js"
)
@pytest.mark.unit
def test_p0_1_handle_node_model_change_defined(app_js_content):
"""P0-1: handleNodeModelChange must be defined in app.js."""
assert _is_function_defined(app_js_content, "handleNodeModelChange"), (
"handleNodeModelChange is called in overview/routing diagrams but has 0 definitions in app.js"
)
@pytest.mark.unit
def test_p0_1_handle_refresh_provider_models_defined(app_js_content):
"""P0-1: handleRefreshProviderModels must be defined in app.js."""
assert _is_function_defined(app_js_content, "handleRefreshProviderModels"), (
"handleRefreshProviderModels is called in overview/routing diagrams but has 0 definitions in app.js"
)
@pytest.mark.unit
def test_p0_1_check_updates_defined(app_js_content):
"""P0-1: checkUpdates must be defined in app.js."""
assert _is_function_defined(app_js_content, "checkUpdates"), (
"checkUpdates is called at DOMContentLoaded and button click but has 0 definitions in app.js"
)
@pytest.mark.unit
def test_p0_1_all_html_and_template_handlers_defined(app_js_content, index_html_content):
"""P0-1: All inline event handlers used in HTML and template literals must have matching definitions in app.js."""
combined = index_html_content + "\n" + app_js_content
handler_pattern = re.compile(r'on(?:click|change|submit|input|keydown|keyup|dragstart|dragover|dragleave|drop|dragend)\s*=\s*["\x27](?:return\s+)?([a-zA-Z0-9_$]+)\s*\(', re.IGNORECASE)
used_handlers = set(handler_pattern.findall(combined))
assert used_handlers, "No event handlers found in HTML/JS"
missing = [h for h in sorted(used_handlers) if not _is_function_defined(app_js_content, h)]
assert not missing, f"The following handlers are called in UI but missing function definitions: {missing}"
@pytest.mark.unit
def test_p0_1_wizard_contracts_and_actions(app_js_content):
"""P0-1: Wizard must use startDeviceAuth, startRedirectAuth, add_account and support manual slot selection."""
assert "showWizardStep1" in app_js_content
assert "showWizardStep2" in app_js_content
assert "showWizardStep3" in app_js_content
assert "finishAddAccount" in app_js_content
# Wizard slot selection & SSH port-forward tunnel hints
assert "buildSlotOptions" in app_js_content
assert "startDeviceAuth" in app_js_content
assert "startRedirectAuth" in app_js_content
assert "wiz-redirect-tunnel" in app_js_content or "ssh -L" in app_js_content
# Base url manual input for local LLM
assert "wiz-base-url-input" in app_js_content
@pytest.mark.unit
def test_p0_1_node_dom_execution_contract():
"""P0-1: Execute targeted Node.js script to simulate DOM loading and verify all 5 handlers are callable."""
res = subprocess.run(
["node", "tests/test_web_handlers_dom_contract.js"],
capture_output=True,
text=True,
)
assert res.returncode == 0, f"Node DOM contract test failed:\nSTDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}"
@pytest.mark.unit
def test_p0_1_no_elementsmap_in_production_app_js(app_js_content):
"""P0-1 regression: elementsMap must NOT appear in production app.js.
elementsMap is a test-only artifact its use in openAddAccountWizard caused
a ReferenceError in the real browser where elementsMap is undefined.
Any test-only element-caching must live inside the Node test sandbox only.
"""
assert 'elementsMap' not in app_js_content, (
"elementsMap must not appear in production app.js — "
"it is a test-only variable. openAddAccountWizard must not reference it."
)
# ─────────────────────────────────────────────────────────────────────────────
# TDD: owner-selected slots for every account-connect flow (P0-1 remainder)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_tdd_grok_codex_shows_slot_picker_before_device_auth(app_js_content):
"""TDD GAP-1: Grok/Codex device-auth must show owner slot picker BEFORE startDeviceAuth.
The grok/openai-codex branch in showWizardStep2 must include wiz-device-slot select.
"""
# Find the grok/openai-codex branch in showWizardStep2
# It must have a slot select element, not just device-auth-box
assert "wiz-device-slot" in app_js_content, (
"GAP-1: grok/codex flow must have 'wiz-device-slot' select element in the template"
)
# The slot select must use buildSlotOptions to populate options
assert "buildSlotOptions" in app_js_content
@pytest.mark.unit
def test_tdd_finish_add_account_sends_profile_id(app_js_content):
"""TDD GAP-2: finishAddAccount must send chosen profile_id/slot in add_account payload.
It must NOT silently find_free_slot for the owner.
"""
# finishAddAccount must read wiz-device-slot OR wiz-redirect-slot
# and include profile_id in the payload
assert "wiz-device-slot" in app_js_content or "wiz-redirect-slot" in app_js_content, (
"GAP-2: finishAddAccount must read a slot select element (wiz-device-slot or wiz-redirect-slot)"
)
# The payload passed to add_account must include profile_id
# Look for pattern: payload.profile_id or payload['profile_id']
finish_fn_match = re.search(
r'async function finishAddAccount\([^)]*\)\s*\{(.*?)\n\}',
app_js_content,
re.DOTALL
)
assert finish_fn_match, "finishAddAccount function not found"
finish_body = finish_fn_match.group(1)
# Must contain profile_id in the payload
assert "profile_id" in finish_body, (
"GAP-2: finishAddAccount must include 'profile_id' in the add_account payload"
)
@pytest.mark.unit
def test_tdd_wizard_step3_dynamic_roles_from_snapshot_routing(app_js_content):
"""TDD GAP-3: Wizard step 3 must NOT hardcode only 6 legacy role IDs.
It must build role options from currentSnapshot.routing if snapshot agents exist.
"""
# Find showWizardStep3 function
step3_match = re.search(
r'function showWizardStep3\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\n$)',
app_js_content,
re.DOTALL
)
assert step3_match, "showWizardStep3 function not found"
step3_body = step3_match.group(1)
# Must reference currentSnapshot.routing for dynamic role building
assert "currentSnapshot.routing" in step3_body, (
"GAP-3: showWizardStep3 must use currentSnapshot.routing to build role options dynamically"
)
# The dynamic branch (roleIds.length > 0) must NOT be the fallback hardcoded list.
# Check that roleIds (from Object.keys(routing)) is used to build options dynamically.
# The presence of "roleIds.map" or "Object.keys(routing)" indicates dynamic path.
assert ("roleIds.map" in step3_body or "Object.keys" in step3_body), (
"GAP-3: showWizardStep3 must use roleIds.map(...) to build options dynamically from currentSnapshot.routing"
)
@pytest.mark.unit
def test_tdd_all_new_template_handlers_defined(app_js_content):
"""TDD GAP-4: All onclick/onchange in app.js template literals must have function definitions.
After GAP-1/2/3 fixes, verify no new dangling handlers were introduced.
"""
combined = app_js_content # index.html already checked in existing test
handler_pattern = re.compile(
r'on(?:click|change|submit|input|keydown|keyup|dragstart|dragover|dragleave|drop|dragend)\s*=\s*["\'](?:return\s+)?([a-zA-Z0-9_$]+)\s*\(',
re.IGNORECASE
)
used_handlers = set(handler_pattern.findall(combined))
if not used_handlers:
pytest.skip("No event handlers found in app.js template literals")
missing = [h for h in sorted(used_handlers) if not _is_function_defined(app_js_content, h)]
assert not missing, f"GAP-4: Handlers called in templates but missing definitions: {missing}"
# ─────────────────────────────────────────────────────────────────────────────
# TDD: P0-2b OpenRouter / NVIDIA wizard buttons
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_p0_2b_wizard_openrouter_button_exists(app_js_content):
"""P0-2b: showWizardStep1 must have onclick showWizardStep2('openrouter')."""
assert "showWizardStep2('openrouter')" in app_js_content, (
"P0-2b: showWizardStep1 is missing onclick showWizardStep2('openrouter') button"
)
@pytest.mark.unit
def test_p0_2b_wizard_nvidia_button_exists(app_js_content):
"""P0-2b: showWizardStep1 must have onclick showWizardStep2('nvidia')."""
assert "showWizardStep2('nvidia')" in app_js_content, (
"P0-2b: showWizardStep1 is missing onclick showWizardStep2('nvidia') button"
)
@pytest.mark.unit
def test_p0_2b_wizard_openrouter_step2_has_slot_and_base_url(app_js_content):
"""P0-2b: showWizardStep2('openrouter') must render slot picker + base URL input."""
# The openrouter branch must use wiz-redirect-slot and wiz-base-url-input
step2_match = re.search(
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
app_js_content,
re.DOTALL
)
assert step2_match, "showWizardStep2 function not found"
step2_body = step2_match.group(1)
# Find the openrouter branch
openrouter_branch = re.search(
r"providerId\s*===\s*['\"]openrouter['\"]",
step2_body
)
assert openrouter_branch, "showWizardStep2 has no openrouter branch"
# The openrouter branch must have slot selector and base URL input
openrouter_section = step2_body[openrouter_branch.start():]
# Find the next provider branch or end
next_branch = re.search(
r"if\s*\(\s*providerId\s*===\s*['\"]",
openrouter_section[20:]
)
openrouter_chunk = openrouter_section[:20 + next_branch.start()] if next_branch else openrouter_section[20:]
assert "wiz-redirect-slot" in openrouter_chunk, (
"P0-2b: openrouter branch must use wiz-redirect-slot for slot selection"
)
assert "wiz-base-url-input" in openrouter_chunk, (
"P0-2b: openrouter branch must have wiz-base-url-input for optional base URL"
)
@pytest.mark.unit
def test_p0_2b_wizard_nvidia_step2_has_slot_and_base_url(app_js_content):
"""P0-2b: showWizardStep2('nvidia') must render slot picker + base URL input."""
step2_match = re.search(
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
app_js_content,
re.DOTALL
)
assert step2_match, "showWizardStep2 function not found"
step2_body = step2_match.group(1)
# nvidia may be in a combined OR branch with openrouter, or standalone
nvidia_branch = re.search(
r"providerId\s*===\s*['\"]nvidia['\"]",
step2_body
)
assert nvidia_branch, "showWizardStep2 has no nvidia branch"
nvidia_section = step2_body[nvidia_branch.start():]
next_branch = re.search(
r"if\s*\(\s*providerId\s*===\s*['\"]",
nvidia_section[20:]
)
nvidia_chunk = nvidia_section[:20 + next_branch.start()] if next_branch else nvidia_section[20:]
assert "wiz-redirect-slot" in nvidia_chunk, (
"P0-2b: nvidia branch must use wiz-redirect-slot for slot selection"
)
assert "wiz-base-url-input" in nvidia_chunk, (
"P0-2b: nvidia branch must have wiz-base-url-input for optional base URL"
)
@pytest.mark.unit
def test_p0_2b_proceedtostep3_sets_slot_for_openrouter_and_nvidia(app_js_content):
"""P0-2b: proceedToWizardStep3 must set window._wiz_device_profile for openrouter and nvidia."""
proceed_match = re.search(
r"function proceedToWizardStep3\([^)]*\)\s*\{(.*?)(?=\nfunction|\nasync function|\n$)",
app_js_content,
re.DOTALL
)
assert proceed_match, "proceedToWizardStep3 function not found"
proceed_body = proceed_match.group(1)
# openrouter and nvidia must be listed alongside antigravity/claude redirect-auth flow
# so that window._wiz_device_profile is set before showWizardStep3
assert "openrouter" in proceed_body, (
"P0-2b: proceedToWizardStep3 must handle openrouter to persist slot selection"
)
assert "nvidia" in proceed_body, (
"P0-2b: proceedToWizardStep3 must handle nvidia to persist slot selection"
)
# ─────────────────────────────────────────────────────────────────────────────
# TDD: P0-3 Local LLM server discovery button in wizard step 2
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.unit
def test_p0_3_discover_local_models_action_referenced(app_js_content):
"""P0-3: discover_local_models action must be referenced in app.js."""
assert "discover_local_models" in app_js_content, (
"P0-3: discover_local_models action must be referenced in app.js"
)
@pytest.mark.unit
def test_p0_3_discover_button_in_local_wizard_step2(app_js_content):
"""P0-3: showWizardStep2 local branch must have 'Найти на этом компьютере' button
that calls discoverLocalServers or executeAction('discover_local_models')."""
step2_match = re.search(
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
app_js_content,
re.DOTALL
)
assert step2_match, "showWizardStep2 function not found"
step2_body = step2_match.group(1)
# Find the local provider branch
local_branch = re.search(
r"providerId\s*===\s*['\"]local['\"]",
step2_body
)
assert local_branch, "showWizardStep2 has no local provider branch"
local_section = step2_body[local_branch.start():]
# Find the extent of this branch (until next major provider branch or end)
next_branch = re.search(
r"if\s*\(\s*providerId\s*===\s*['\"]",
local_section[20:]
)
local_chunk = local_section[:20 + next_branch.start()] if next_branch else local_section[20:]
# Must contain the discover button label
assert "Найти на этом компьютере" in local_chunk, (
"P0-3: local wizard step 2 must have 'Найти на этом компьютере' button"
)
# Must call discover_local_models
assert "discover_local_models" in local_chunk, (
"P0-3: local wizard step 2 must call discover_local_models action"
)
@pytest.mark.unit
def test_p0_3_discover_fills_base_url_input(app_js_content):
"""P0-3: discovered servers list must fill wiz-base-url-input on selection."""
# There must be a handler that sets wiz-base-url-input value from discovered server
assert "wiz-base-url-input" in app_js_content, (
"P0-3: wiz-base-url-input must be present for filling discovered server URL"
)
# The handler for discovered servers must reference both servers result and wiz-base-url-input
# Look for a function that handles discover_local_models response
discover_match = re.search(
r"function\s+(\w*discover\w*)\s*\(",
app_js_content,
re.IGNORECASE
)
assert discover_match, "P0-3: no function handling discover_local_models response found"
handler_name = discover_match.group(1)
handler_match = re.search(
rf"function\s+{re.escape(handler_name)}\s*\([^)]*\)\s*\{{(.*?)\n\}}",
app_js_content,
re.DOTALL
)
assert handler_match, f"handler {handler_name} body not found"
handler_body = handler_match.group(1)
assert "wiz-base-url-input" in handler_body, (
"P0-3: discover handler must fill wiz-base-url-input with selected server URL"
)

View file

@ -53,19 +53,18 @@ def test_p0_1_installer_dependencies():
cs_content = setup_cs.read_text(encoding="utf-8")
assert "EnsurePythonDependencies" in cs_content, "Canonical installer must define dependency checking and installation"
assert "customtkinter" in cs_content and "pillow" in cs_content.lower(), "Canonical installer must install customtkinter and Pillow"
assert "fastapi" in cs_content and "uvicorn" in cs_content.lower(), "Canonical installer must install fastapi and uvicorn"
assert "HERMES_HUB_IMPORT_OK" in cs_content, "Canonical installer must execute post-install import smoke test"
assert "assets" in cs_content, "Canonical installer must deploy branding and UI assets"
# 2. Verify Runtime Dependency Availability
pytest.importorskip("customtkinter")
import customtkinter
from PIL import Image
import fastapi
import uvicorn
import psutil
import yaml
assert customtkinter is not None
assert Image is not None
assert fastapi is not None
assert uvicorn is not None
assert psutil is not None
assert yaml is not None

View file

@ -1,396 +0,0 @@
"""Acceptance coverage for UI state contract v1.1 fields."""
from __future__ import annotations
from dataclasses import replace
import time
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import AccountCardWidget, QuotaBucketWidget
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.routing_view import RoutingRoleWidget
from antigravity_provider.router.ui.views.team_view import AgentCardWidget
from antigravity_provider.router.unified_health import (
AgentViewModel,
PipelineNode,
ProfileViewModel,
ProviderSummary,
RolePipeline,
SystemReadiness,
)
@pytest.fixture(scope="module")
def ui_root(tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
yield root
root.destroy()
def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> ProfileViewModel:
return ProfileViewModel(
profile_id="account-1",
display_name="Primary",
account_identity="user@example.test",
provider="antigravity",
provider_display_name="Google Antigravity",
assigned_roles=["developer-1"],
primary_role="developer-1",
is_main_account=True,
is_main_orchestrator=False,
auth_state="AUTHENTICATED",
health_state="healthy",
health_label_ru="Работает",
model_states={},
cooldown_remaining_sec=0,
last_checked_at="12:00:00",
enabled=True,
is_cold_spare=False,
is_empty_slot=False,
plan_code=plan_code,
plan_source=plan_source,
)
def _readiness() -> SystemReadiness:
return SystemReadiness(
state="healthy",
title_ru="Система готова",
summary_ru="Все роли доступны",
roles_ready_count=1,
total_roles=1,
accounts_connected_count=1,
total_accounts=1,
providers_ready_count=1,
total_providers=1,
)
@pytest.mark.ui
def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None:
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
try:
card.pack()
card.update_account(_profile("PRO", "provider_api"))
ui_root.update_idletasks()
assert card.plan_badge.label.cget("text") == "Тариф PRO"
assert card.plan_badge.winfo_manager() == "pack"
card.update_account(_profile("PRO", "inferred"))
ui_root.update_idletasks()
assert card.plan_badge.label.cget("text") == "Тариф PRO • выведено"
assert card.plan_badge.label.cget("text_color") == Theme.TEXT_MUTED
card.update_account(_profile("UNKNOWN", "unknown"))
ui_root.update_idletasks()
assert card.plan_badge.winfo_manager() == ""
finally:
card.destroy()
@pytest.mark.ui
def test_live_quota_overrides_stale_exhausted_card_status(ui_root) -> None:
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
profile = replace(_profile(), health_state="quota_exhausted", health_label_ru="Квота исчерпана")
snapshot = QuotaSnapshot(
account_id="account-1",
provider="antigravity",
source="provider_api",
buckets=[
QuotaBucket(id="claude", display_name="Claude", remaining_percent=100.0),
QuotaBucket(id="gemini", display_name="Gemini", remaining_percent=100.0),
],
)
try:
card.pack()
card.update_account(profile, snapshot)
ui_root.update_idletasks()
assert card.status.label.cget("text") == "Работает"
assert card.status.dot.cget("text_color") == Theme.STATUS_HEALTHY
finally:
card.destroy()
@pytest.mark.ui
def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None:
widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h")
try:
widget.pack()
widget.update_bucket("Claude 5h", None, unavailable_reason="Провайдер не вернул лимит")
ui_root.update_idletasks()
assert widget.bar.detail.cget("text") == "Н"
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEUTRAL
assert "Провайдер не вернул лимит" in widget.unavailable_reason.cget("text")
widget.update_bucket("Claude 5h", 0.0)
ui_root.update_idletasks()
assert widget.bar.detail.cget("text") == "0%"
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEGATIVE
assert widget.unavailable_reason.winfo_manager() == ""
finally:
widget.destroy()
@pytest.mark.ui
def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None:
agent = AgentViewModel(
role_id="developer-1",
role_name_ru="Кодер 1",
role_description_ru="Основной кодер",
assigned_profile_id="account-2",
assigned_display_name="Reserve",
provider="codex",
provider_display_name="OpenAI Codex",
model="gpt-5",
account_identity="reserve@example.test",
routing_position="Fallback 1",
status="healthy",
status_label_ru="Работает",
is_active=True,
is_main_orchestrator=False,
session_id="session-42",
active_quota_status="warning",
active_quota_label="Осталось 12%",
)
team_card = AgentCardWidget(ui_root)
pipeline = RolePipeline(
role_id="developer-1",
role_name_ru="Кодер 1",
default_model="gpt-5",
max_failover=2,
session_affinity=True,
active_profile_id="account-2",
nodes=[
PipelineNode(
profile_id="account-1",
display_name="Primary",
provider="Google Antigravity",
model="gemini-2.5-pro",
status="quota_exhausted",
status_label_ru="Исчерпан",
is_active=False,
account_identity="primary@example.test",
quota_status="exhausted",
failover_reason="Исчерпана квота (429)",
),
PipelineNode(
profile_id="account-2",
display_name="Reserve",
provider="OpenAI Codex",
model="gpt-5",
status="healthy",
status_label_ru="Работает",
is_active=True,
account_identity="reserve@example.test",
quota_status="warning",
),
],
)
route = RoutingRoleWidget(ui_root, pipeline)
try:
team_card.pack()
route.pack()
team_card.update_agent(agent)
route.update_from_pipeline(pipeline)
ui_root.update_idletasks()
assert "Осталось 12%" in team_card.quota_lbl.cget("text")
assert "session-42" in team_card.quota_lbl.cget("text")
assert "Исчерпана квота (429)" in route._nodes["account-1"].failover.cget("text")
assert route._nodes["account-2"].failover.cget("text") == ""
assert "Квота: исчерпана" == route._nodes["account-1"].quota.cget("text")
finally:
route.destroy()
team_card.destroy()
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
agent = AgentViewModel(
role_id="developer-1",
role_name_ru="Кодер 1",
role_description_ru="Основной кодер",
assigned_profile_id="ag-w1",
assigned_display_name="Primary",
provider="antigravity",
provider_display_name="Google Antigravity",
model="gemini-3.1-pro",
account_identity="user@example.test",
routing_position="Primary",
status="healthy",
status_label_ru="Работает",
is_active=True,
is_main_orchestrator=False,
active_quota_status="healthy",
active_quota_label="Осталось 73%",
)
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[agent],
providers=[],
routing={},
quotas={
"ag-w1": QuotaSnapshot(
account_id="ag-w1",
provider="antigravity",
source="provider_api",
buckets=[
QuotaBucket(
id="antigravity.gemini.7d",
display_name="Gemini • неделя",
model_family="gemini",
remaining_percent=73.0,
)
],
)
},
)
label, percent = DashboardView._agent_quota_measurement(snapshot, agent)
assert label == "Осталось 73%"
assert percent == 73.0
@pytest.mark.ui
def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
snapshot = HubSnapshot(
generation=7,
seq=11,
timestamp=time.time() - 301,
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[],
providers=[],
routing={},
quotas={},
is_stale=True,
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
label = view.snapshot_freshness.cget("text")
assert "#11" in label
assert "устарели" in label
assert view.snapshot_freshness.cget("text_color") == Theme.STATUS_WARNING
finally:
view.destroy()
@pytest.mark.ui
def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_root) -> None:
profiles = [
replace(
_profile(),
profile_id=f"ag-{index}",
account_identity=f"user{index}@example.test",
email=f"user{index}@example.test",
)
for index in range(6)
]
readiness = replace(_readiness(), accounts_connected_count=6, total_accounts=6)
provider = ProviderSummary(
provider_id="antigravity",
provider_name="Google Antigravity",
total_slots=10,
connected_count=6,
online_count=6,
auth_required_count=0,
quota_exhausted_count=0,
cold_spare_count=0,
discovered_models=["gemini-3.7-flash"],
last_refresh_at="12:00:00",
)
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={"antigravity": profiles},
all_profiles={profile.profile_id: profile for profile in profiles},
readiness=readiness,
agents=[],
providers=[provider],
routing={},
quotas={},
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
assert view.agents_metric.val_label.cget("text") == "6"
assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text")
assert not hasattr(view, "realtime")
assert view.route_diagram.provider_slots[0].winfo_manager() == "place"
finally:
view.destroy()
@pytest.mark.unit
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
snapshot = QuotaSnapshot(
account_id="account-1",
provider="antigravity",
buckets=[QuotaBucket(id="b", display_name="Gemini 5h")],
unavailable_reason="Авторизация недоступна",
)
assert snapshot.buckets[0].remaining_percent is None
assert snapshot.unavailable_reason == "Авторизация недоступна"
@pytest.mark.ui
def test_dashboard_renders_all_five_providers_in_order(ui_root) -> None:
provider_ids = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]
providers = [
ProviderSummary(
provider_id=pid,
provider_name=pid.upper(),
total_slots=2,
connected_count=1,
online_count=1,
auth_required_count=0,
quota_exhausted_count=0,
cold_spare_count=0,
discovered_models=["model-1"],
last_refresh_at="12:00:00",
)
for pid in provider_ids
]
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[],
providers=providers,
routing={},
quotas={},
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
assert len(view._provider_cards) == 5
assert set(view._provider_cards.keys()) == set(provider_ids)
for slot in view.route_diagram.provider_slots[:5]:
assert slot.winfo_manager() == "place"
finally:
view.destroy()

View file

@ -1,84 +0,0 @@
"""Regression checks for the Hermes Hub UI design-system foundation."""
from __future__ import annotations
import inspect
import typing
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import components
from antigravity_provider.router.ui.theme import Theme
@pytest.mark.unit
def test_required_component_library_is_available() -> None:
required = {
"HubCard",
"SectionHeader",
"StatusBadge",
"PlanBadge",
"ProviderBadge",
"QuotaBar",
"QuotaBucketWidget",
"AccountCardWidget",
"AgentCardWidget",
"RouteTargetWidget",
"EmptyState",
"SearchField",
"FilterButton",
"ActionButton",
"IconButton",
"ConfirmDialog",
"Toast",
}
assert required <= set(vars(components))
@pytest.mark.unit
def test_brand_gold_is_not_used_as_healthy_status() -> None:
assert Theme.COLOR_BRAND == Theme.ACCENT
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
assert Theme.COLOR_POSITIVE != Theme.COLOR_BRAND
assert components._semantic_color("healthy") == Theme.COLOR_POSITIVE
assert components._semantic_color("reserve") == Theme.COLOR_CAUTION
assert components._semantic_color("error") == Theme.COLOR_NEGATIVE
assert components._semantic_color("unknown") == Theme.COLOR_NEUTRAL
@pytest.mark.unit
def test_layout_and_typography_tokens_are_centralized() -> None:
assert Theme.PAGE_PAD_X in {Theme.SPACE_MD, Theme.SPACE_LG, Theme.SPACE_XL}
assert Theme.CARD_PAD_X in {Theme.SPACE_SM, Theme.SPACE_MD, Theme.SPACE_LG}
assert Theme.HEIGHT_INPUT == Theme.HEIGHT_BTN_MD - 2
assert Theme.font_title() == Theme.font_title_page()
assert Theme.font_title_page()[1] > Theme.font_heading()[1] > Theme.font_body()[1]
@pytest.mark.unit
def test_quota_bucket_has_stable_key_and_in_place_update_api() -> None:
signature = inspect.signature(components.QuotaBucketWidget.__init__)
assert "bucket_key" in signature.parameters
assert callable(getattr(components.QuotaBucketWidget, "update_bucket"))
@pytest.mark.unit
def test_unknown_quota_is_supported_explicitly() -> None:
signature = inspect.signature(components.QuotaBar.set_value)
annotation = signature.parameters["value"].annotation
# inspect resolves the real typing object here (components.py does not use
# `from __future__ import annotations`), so compare types, not source text.
assert type(None) in typing.get_args(annotation), (
"QuotaBar.set_value must accept None so an unknown quota can be rendered "
f"as such instead of a fabricated number; got {annotation!r}"
)
@pytest.mark.unit
def test_long_identity_is_ellipsized_without_losing_source_value() -> None:
identity = "very.long.account.identity.for.daily.operations@example.enterprise"
shortened = components.ellipsize_text(identity, 28)
assert len(shortened) == 28
assert shortened.endswith("")
assert identity.startswith(shortened[:-1])

View file

@ -1,147 +0,0 @@
"""Acceptance tests for the approved three-theme mockup redesign."""
from __future__ import annotations
import ast
from pathlib import Path
import re
from types import SimpleNamespace
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.components import AccountCardWidget
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router import hermes_hub_app as app_module
@pytest.fixture(scope="module")
def ui_root(tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
yield root
root.destroy()
@pytest.mark.unit
def test_every_restored_account_handler_has_a_ui_trigger() -> None:
required = {"test", "set_main", "set_orchestrator", "assign_role"}
configured = {action for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS}
assert configured == required
app_path = Path("src/antigravity_provider/router/hermes_hub_app.py")
tree = ast.parse(app_path.read_text(encoding="utf-8"))
handled = {
node.comparators[0].value
for node in ast.walk(tree)
if isinstance(node, ast.Compare)
and isinstance(node.left, ast.Name)
and node.left.id == "action"
and len(node.ops) == 1
and isinstance(node.ops[0], ast.Eq)
and len(node.comparators) == 1
and isinstance(node.comparators[0], ast.Constant)
and isinstance(node.comparators[0].value, str)
}
assert required <= handled
@pytest.mark.unit
def test_three_schemes_have_identical_token_coverage() -> None:
assert set(Theme.PALETTES) == {"dark", "hybrid", "light"}
token_sets = [set(palette) for palette in Theme.PALETTES.values()]
assert token_sets[0] == token_sets[1] == token_sets[2]
backgrounds = {palette["BG_WINDOW"] for palette in Theme.PALETTES.values()}
assert len(backgrounds) == 3
@pytest.mark.unit
def test_ui_colors_are_centralized_in_theme_tokens() -> None:
roots = [
Path("src/antigravity_provider/router/ui"),
Path("src/antigravity_provider/router/hermes_hub_app.py"),
]
offenders = []
for root in roots:
files = [root] if root.is_file() else list(root.rglob("*.py"))
for file in files:
if file.name == "theme.py":
continue
if re.search(r"#[0-9A-Fa-f]{3,8}", file.read_text(encoding="utf-8")):
offenders.append(str(file))
assert offenders == []
@pytest.mark.unit
def test_approved_mockup_numbers_are_not_shipped_as_placeholders() -> None:
source_files = list(Path("src/antigravity_provider/router/ui").rglob("*.py"))
source_files.append(Path("src/antigravity_provider/router/hermes_hub_app.py"))
source = "\n".join(file.read_text(encoding="utf-8") for file in source_files)
for fictional_value in ("78%", "09:0021:00", "842 rps", "99.98%", "42 Мбит/с"):
assert fictional_value not in source
@pytest.mark.unit
def test_approved_logo_and_consistent_sidebar_icon_system_are_wired() -> None:
assert Path("assets/branding/logo/logo_approved.png").is_file()
assets_source = Path("src/antigravity_provider/router/ui/assets.py").read_text(encoding="utf-8")
app_source = Path("src/antigravity_provider/router/hermes_hub_app.py").read_text(encoding="utf-8")
assert 'self.logo_dir / "logo_approved.png"' in assets_source
assert "def get_nav_icon" in assets_source
assert "get_nav_icon(icon, size=19)" in app_source
@pytest.mark.unit
def test_apply_scheme_updates_shared_semantic_aliases() -> None:
original = Theme.current_scheme
try:
for scheme in Theme.SCHEMES:
assert Theme.apply_scheme(scheme) == scheme
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
assert Theme.COLOR_CAUTION == Theme.STATUS_WARNING
assert Theme.COLOR_BRAND == Theme.ACCENT
finally:
Theme.apply_scheme(original)
@pytest.mark.ui
def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
calls = []
card = AccountCardWidget(
ui_root,
"profile-1",
"user@example.test",
"OpenAI Codex",
on_action=lambda action, profile: calls.append((action, profile.profile_id)),
)
card.profile_model = SimpleNamespace(profile_id="profile-1")
try:
card.pack()
for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS:
card.action_buttons[action].invoke()
assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS]
finally:
card.destroy()
@pytest.mark.ui
def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None:
policy = SimpleNamespace(preferred_chain=[])
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"manager": policy}))
monkeypatch.setattr(
app_module.AutoAssigner,
"assign_profile_to_role",
lambda *_args, **_kwargs: (False, "Профиль не найден"),
)
ui_root._show_account_action_result = lambda *_args: None
modal = app_module.HermesHubApp._open_assign_role_modal(ui_root, "missing", "Claude")
try:
modal.save_button.invoke()
modal.update_idletasks()
assert modal.winfo_exists()
assert "Профиль не найден" in modal.result_label.cget("text")
finally:
modal.destroy()

View file

@ -1,192 +0,0 @@
"""Acceptance coverage for the phase 26 snapshot-driven UI."""
from __future__ import annotations
import time
from dataclasses import replace
from pathlib import Path
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import AccountCardWidget
from antigravity_provider.router.ui.views.accounts_view import AccountsView
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.routing_view import RoutingView
from antigravity_provider.router.ui.views.team_view import TeamView
from antigravity_provider.router.unified_health import ProfileViewModel, SystemReadiness
@pytest.fixture(scope="module")
def ui_root(tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
yield root
root.destroy()
def _profile(index: int) -> ProfileViewModel:
return ProfileViewModel(
profile_id=f"account-{index}",
display_name=f"Worker {index}",
account_identity=f"user-{index}@example.test",
provider="antigravity",
provider_display_name="Google Antigravity",
assigned_roles=["coder" if index else "manager"],
primary_role="coder" if index else "manager",
is_main_account=index == 0,
is_main_orchestrator=index == 0,
auth_state="AUTHENTICATED",
health_state="healthy",
health_label_ru="Работает",
model_states={},
cooldown_remaining_sec=0,
last_checked_at="12:00:00",
enabled=True,
is_cold_spare=False,
is_empty_slot=False,
email=f"mail-{index}@example.test",
plan="PRO",
plan_code="PRO",
preferred_models=["gemini-2.5-pro"],
)
def _quota(profile_id: str, remaining: float | None = None) -> QuotaSnapshot:
return QuotaSnapshot(
account_id=profile_id,
provider="antigravity",
buckets=[
QuotaBucket(
id="antigravity.gemini.5h",
display_name="Gemini 5h",
model_family="gemini",
remaining_percent=remaining,
period="5h",
)
],
source="baseline",
)
def _snapshot(count: int = 50, changed_remaining: float | None = None) -> HubSnapshot:
profiles = [_profile(index) for index in range(count)]
quotas = {
profile.profile_id: _quota(
profile.profile_id,
changed_remaining if profile.profile_id == "account-0" else None,
)
for profile in profiles
}
return HubSnapshot(
generation=1 if changed_remaining is None else 2,
seq=1 if changed_remaining is None else 2,
timestamp=time.time(),
profiles_by_provider={"antigravity": profiles},
all_profiles={profile.profile_id: profile for profile in profiles},
readiness=SystemReadiness(
state="healthy",
title_ru="Система готова",
summary_ru="Все назначенные роли доступны",
roles_ready_count=6,
total_roles=6,
accounts_connected_count=count,
total_accounts=count,
providers_ready_count=1,
total_providers=1,
),
agents=[],
providers=[],
routing={},
quotas=quotas,
)
@pytest.mark.unit
def test_identity_priority_and_plan_badge_suppression() -> None:
profile = _profile(1)
assert AccountCardWidget.resolve_identity(profile) == profile.email
assert "PlanBadge" not in Path("src/antigravity_provider/router/ui/views/accounts_view.py").read_text(
encoding="utf-8"
)
@pytest.mark.ui
@pytest.mark.timeout(90)
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
view = AccountsView(ui_root)
try:
view.pack(fill="both", expand=True)
view.update_data(_snapshot())
ui_root.update_idletasks()
before = view.render_stats()
card_ids = {key: id(card) for key, card in view._cards.items()}
assert all(card.compact for card in view._cards.values())
view.update_data(_snapshot(changed_remaining=42.0))
ui_root.update_idletasks()
after = view.render_stats()
assert len(view._cards) == 50
assert before["cards_created"] == after["cards_created"] == 50
assert before["cards_destroyed"] == after["cards_destroyed"] == 0
assert before["quota_widgets_created"] == after["quota_widgets_created"] == 50
assert before["quota_widgets_destroyed"] == after["quota_widgets_destroyed"] == 0
assert {key: id(card) for key, card in view._cards.items()} == card_ids
bucket = view._cards["account-0"]._quota_widgets["antigravity.gemini.5h"]
assert "оценка" in bucket.title.cget("text")
finally:
view.destroy()
@pytest.mark.ui
def test_removing_account_destroys_only_its_card_and_views_accept_snapshot(ui_root) -> None:
children = []
try:
view = AccountsView(ui_root)
children.append(view)
first = _snapshot(3)
view.update_data(first)
retained = id(view._cards["account-1"])
profiles = first.profiles_by_provider["antigravity"][1:]
second = replace(
first,
generation=2,
all_profiles={profile.profile_id: profile for profile in profiles},
profiles_by_provider={"antigravity": profiles},
quotas={key: value for key, value in first.quotas.items() if key != "account-0"},
)
view.update_data(second)
assert "account-0" not in view._cards
assert id(view._cards["account-1"]) == retained
assert view.render_stats()["cards_destroyed"] == 1
for child in (DashboardView(ui_root), TeamView(ui_root), RoutingView(ui_root)):
children.append(child)
child.update_data(second)
ui_root.update_idletasks()
finally:
for child in children:
child.destroy()
@pytest.mark.unit
def test_data_views_do_not_call_backend_services() -> None:
view_dir = Path("src/antigravity_provider/router/ui/views")
forbidden = ("HubStateStore", "scan_all(", "AccountQuotaService", "HermesRefreshScheduler", "EventLogService")
for name in (
"accounts_view.py",
"dashboard_view.py",
"team_view.py",
"routing_view.py",
"providers_view.py",
"health_view.py",
"logs_view.py",
):
source = (view_dir / name).read_text(encoding="utf-8")
assert not any(token in source for token in forbidden), f"{name} accesses backend directly"

View file

@ -1,50 +0,0 @@
# -*- coding: utf-8 -*-
"""Unit tests for UI refinement, test action safety, and settings persistence."""
from __future__ import annotations
import json
import os
import tempfile
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.hermes_hub_app import do_test_profile, do_set_main, do_set_orchestrator
from antigravity_provider.router.profile_manager import ProfileAuthManager
def test_test_action_safe_on_unauthenticated():
"""Verify that testing an unauthenticated profile returns an error and never triggers OAuth/browser."""
# ag-spare-2 is unauthenticated
res = do_test_profile("antigravity", "ag-spare-2")
assert res["success"] is False
assert len(res.get("error", "")) > 0
def test_test_action_non_existent_profile():
"""Verify that testing a non-existent profile returns a clean error."""
res = do_test_profile("antigravity", "non_existent_profile_xyz")
assert res["success"] is False
assert len(res.get("error", "")) > 0
def test_set_main_profile_action():
"""Verify that do_set_main updates the default profile."""
# Ensure profile has saved auth
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "valid"}})
# Test setting main profile
ok, msg = do_set_main("antigravity", "ag-w1")
assert ok is True
assert "ag-w1" in msg
# Verify via ProfileAuthManager
main_pid = ProfileAuthManager.get_main_profile("antigravity")
assert main_pid == "ag-w1"
def test_set_orchestrator_action():
"""Verify that do_set_orchestrator updates orchestrator role policy."""
ok, msg = do_set_orchestrator("ag-w2")
assert ok is True
assert "ag-w2" in msg

View file

@ -1,360 +0,0 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import routing_graph as graph_module
from antigravity_provider.router import action_handler
from antigravity_provider.router.ui import add_account_wizard as wizard_module
from antigravity_provider.router.ui.views import team_view as team_module
from antigravity_provider.router import hermes_hub_app as app_module
from antigravity_provider.router.ui.routing_graph import (
GraphEdge,
GraphNode,
RoutingGraph,
RoutingGraphController,
RoutingGraphStore,
default_graph,
validate_graph,
)
def _config():
profiles = {
"orch": SimpleNamespace(provider="openai-codex", preferred_models=["gpt-5"]),
"coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]),
}
roles = {
"manager": SimpleNamespace(preferred_chain=["orch"]),
"developer-1": SimpleNamespace(preferred_chain=["coder"]),
"developer-2": SimpleNamespace(preferred_chain=["coder"]),
"code-reviewer": SimpleNamespace(preferred_chain=["coder"]),
"researcher": SimpleNamespace(preferred_chain=["coder"]),
"tester": SimpleNamespace(preferred_chain=["coder"]),
}
return SimpleNamespace(roles=roles, profiles=profiles)
def test_agent_catalog_uses_backend_cache_without_literal_fallback(monkeypatch):
from antigravity_provider.router.ui import model_catalog
service = SimpleNamespace(get_cached=lambda _provider: {"models": ["provider-model-a", "provider-model-b"]})
monkeypatch.setattr(model_catalog, "_service", lambda: service)
cached = model_catalog.get_cached_models("antigravity")
assert cached.models == ("provider-model-a", "provider-model-b")
def test_agent_catalog_empty_cache_is_explicit(monkeypatch):
from antigravity_provider.router.ui import model_catalog
monkeypatch.setattr(model_catalog, "_service", lambda: None)
cached = model_catalog.get_cached_models("antigravity")
assert cached.models == ()
assert "ещё не подключена" in cached.unavailable_reason
def test_default_graph_migrates_six_roles_without_changing_chains():
config = _config()
before = {key: list(value.preferred_chain) for key, value in config.roles.items()}
graph = default_graph(config)
assert {node.role_id for node in graph.nodes} == set(config.roles)
assert {edge.edge_type for edge in graph.edges} == {"DELEGATE"}
assert before == {key: value.preferred_chain for key, value in config.roles.items()}
def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path):
path = tmp_path / "routing_graph.json"
store = RoutingGraphStore(path)
graph = default_graph(_config())
graph.nodes[0].x = 777
graph.zoom = 1.35
graph.viewport_x = 42
store.save(graph)
loaded = store.load(_config())
assert loaded.nodes[0].x == 777
assert loaded.zoom == 1.35
assert loaded.viewport_x == 42
assert loaded.schema_version == 1
def test_validation_finds_cycle_unreachable_and_missing_profile():
config = _config()
config.roles["code-reviewer"].preferred_chain = ["ghost"]
graph = RoutingGraph(
nodes=[
GraphNode("manager", 0, 0),
GraphNode("developer-1", 1, 0),
GraphNode("code-reviewer", 2, 0),
],
edges=[
GraphEdge("manager", "developer-1"),
GraphEdge("developer-1", "manager"),
],
)
codes = {issue.code for issue in validate_graph(graph, config)}
assert {"cycle", "unreachable", "missing-profile"} <= codes
def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(graph_module, "load_router_config", _config)
monkeypatch.setattr(
graph_module.AutoAssigner,
"assign_profile_to_role",
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
)
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
ok, _message = controller.add_edge("manager", "developer-1", "FALLBACK", "orch")
assert ok
assert calls == [("orch", "developer-1", False)]
def test_undo_redo_and_dirty_state(monkeypatch, tmp_path):
monkeypatch.setattr(graph_module, "load_router_config", _config)
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
original = controller.graph.nodes[0].x
controller.move_node("manager", original + 100, 50)
assert controller.dirty
assert controller.undo()
assert controller.graph.nodes[0].x == original
assert controller.redo()
assert controller.graph.nodes[0].x == original + 100
def test_graph_store_handles_twenty_nodes(tmp_path):
graph = RoutingGraph(nodes=[GraphNode(f"role-{index}", index * 70, index * 35) for index in range(20)])
store = RoutingGraphStore(tmp_path / "routing_graph.json")
store.save(graph)
assert len(store.load(_config()).nodes) == 20
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
from antigravity_provider.router import auto_assigner as assigner_module
config = _config()
calls = []
monkeypatch.setattr(assigner_module, "load_router_config", lambda: config)
monkeypatch.setattr(
assigner_module.AutoAssigner,
"assign_profile_to_role",
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
)
assert wizard_module.ensure_profile_in_routing("orch")[0]
assert calls == []
monkeypatch.setattr(
assigner_module.AutoAssigner,
"get_display_name_and_role",
lambda _profile: ("Новый кодер", "coder", "fallback"),
)
assert wizard_module.ensure_profile_in_routing("new-slot")[0]
assert calls == [("new-slot", "coder", False)]
def test_profile_test_invokes_model_with_timeout_and_records_success(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
class Adapter:
@staticmethod
def invoke(profile, req, *args, **kwargs):
return {"choices": [{"message": {"content": "pong"}}]}
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": False})
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
def mock_mark_success(self, p, m):
self.marked = True
monkeypatch.setattr("antigravity_provider.router.health_tracker.HealthTracker.mark_success", mock_mark_success)
result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is True
assert "Авторизация подтверждена" in result["response"]
def test_profile_test_expired_credentials_fail_immediately(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": True})
result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is False
assert "Авторизация истекла" in result["error"]
def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
calls = []
monkeypatch.setattr(wizard_module, "ensure_profile_in_routing", lambda _profile: (True, "ok"))
monkeypatch.setattr(
wizard_module.EventLogService,
"get",
lambda: SimpleNamespace(log=lambda *args, **kwargs: calls.append(("log", args, kwargs))),
)
from antigravity_provider.router import router_engine
monkeypatch.setattr(
router_engine,
"get_router_engine",
lambda: SimpleNamespace(health=SimpleNamespace(clear_cooldown=lambda profile: calls.append(("clear", profile)))),
)
fake = SimpleNamespace(
target_slot="ag-orch-fallback",
selected_provider="antigravity",
discovered_identity="account",
finish_status_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
on_complete=lambda payload: calls.append(("complete", payload)),
destroy=lambda: calls.append(("destroy",)),
)
wizard_module.AddAccountWizard._finish(fake)
assert ("clear", "ag-orch-fallback") in calls
assert any(item[0] == "log" for item in calls)
assert calls[-1] == ("destroy",)
def test_wizard_stops_when_provider_has_no_real_free_slot(monkeypatch):
calls = []
monkeypatch.setattr(wizard_module.AutoAssigner, "find_free_slot", lambda _provider: None)
fake = SimpleNamespace(
selected_provider="grok",
target_slot="old-value",
_clear_body=lambda: calls.append("clear"),
title_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
_show_no_free_slot=lambda: calls.append("no-slot"),
)
wizard_module.AddAccountWizard._show_step_2_auth(fake)
assert fake.target_slot == ""
assert calls == ["clear", "no-slot"]
def test_wizard_finish_without_slot_shows_error_and_does_not_assign(monkeypatch):
updates = []
monkeypatch.setattr(
wizard_module,
"ensure_profile_in_routing",
lambda _profile: pytest.fail("an invented or empty slot must not be assigned"),
)
fake = SimpleNamespace(
target_slot="",
finish_status_lbl=SimpleNamespace(configure=lambda **kwargs: updates.append(kwargs)),
)
wizard_module.AddAccountWizard._finish(fake)
assert "свободный слот" in updates[-1]["text"]
def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch):
config = SimpleNamespace(
profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
roles={
"manager": SimpleNamespace(preferred_chain=["a", "b", "c"]),
"code-reviewer": SimpleNamespace(preferred_chain=["b"]),
},
)
calls = []
def assign(profile_id, role_id, is_primary=True):
calls.append((profile_id, role_id, is_primary))
if role_id == "spare":
for policy in config.roles.values():
policy.preferred_chain = [item for item in policy.preferred_chain if item != profile_id]
return True, "spare"
chain = config.roles[role_id].preferred_chain
chain = [item for item in chain if item != profile_id]
if is_primary:
chain.insert(0, profile_id)
else:
chain.append(profile_id)
config.roles[role_id].preferred_chain = chain
return True, "assigned"
monkeypatch.setattr(team_module, "load_router_config", lambda: config)
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
ok, _message = team_module.persist_role_chain("manager", ["c", "a"])
assert ok
assert config.roles["manager"].preferred_chain == ["c", "a"]
assert config.roles["code-reviewer"].preferred_chain == ["b"]
assert ("b", "spare", False) in calls
def test_account_action_result_is_sent_to_originating_card():
calls = []
accounts = SimpleNamespace(
show_action_result=lambda profile_id, message, success: calls.append(
("card", profile_id, message, success)
)
)
fake = SimpleNamespace(
_views={"accounts": accounts},
_show_toast=lambda message: calls.append(("toast", message)),
)
app_module.HermesHubApp._show_account_action_result(fake, "profile-1", "Не найден", False)
assert calls[0] == ("card", "profile-1", "Не найден", False)
assert calls[1][0] == "toast"
def test_device_code_step_contains_numbered_instructions_and_copy_actions():
source = Path(wizard_module.__file__).read_text(encoding="utf-8")
assert "1. Откройте ссылку" in source
assert "2. Введите на странице код:" in source
assert "3. Подтвердите доступ" in source
assert source.count('text="Копировать ссылку"') == 2
assert source.count('text="📋 Копировать код"') == 2
def test_grok_slot_is_registered_before_role_assignment(monkeypatch):
config = SimpleNamespace(profiles={})
saved = []
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
monkeypatch.setattr(
"antigravity_provider.router.auto_assigner.load_router_config",
lambda: config,
)
monkeypatch.setattr(
"antigravity_provider.router.auto_assigner.save_router_config",
lambda current: saved.append(current) or True,
)
ok, _message = wizard_module.AutoAssigner.ensure_profile_definition("grok", "grok-orch")
assert ok
assert config.profiles["grok-orch"].provider == "grok"
# Список моделей заполняется обнаружением у провайдера, а не литералом.
# Пока обнаружение не выполнено, профиль остаётся без моделей: профиль
# без списка честнее профиля с выдуманным. Раньше сюда подставлялось
# "grok-3", и по тому же образцу в конфигурацию владельца попал
# gemini-3.7-flash, которого у провайдера не существует.
assert config.profiles["grok-orch"].preferred_models == []
assert saved == [config]
def test_opencode_paste_targets_entry_and_reports_success():
calls = []
entry = SimpleNamespace(
clipboard_get=lambda: " opencode-token-123 ",
delete=lambda *_args: calls.append("delete"),
insert=lambda *_args: calls.append(("insert", _args[-1])),
focus_set=lambda: calls.append("focus"),
icursor=lambda *_args: calls.append("cursor"),
)
status = SimpleNamespace(configure=lambda **kwargs: calls.append(("status", kwargs["text"])))
fake = SimpleNamespace(key_entry=entry, key_status_lbl=status)
wizard_module.AddAccountWizard._paste_into_entry(fake, entry)
assert ("insert", "opencode-token-123") in calls
assert ("status", "✓ Ключ вставлен. Нажмите «Проверить и продолжить».") in calls

View file

@ -1,54 +0,0 @@
"""Manual screenshot harness for every tab in all approved color schemes."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
pytest.importorskip("customtkinter")
pytest.importorskip("PIL")
from PIL import ImageGrab
from antigravity_provider.router.hermes_hub_app import HermesHubApp
def capture_all(output_dir: Path) -> list[Path]:
output_dir.mkdir(parents=True, exist_ok=True)
app = HermesHubApp()
app.geometry("1366x768+20+20")
app.deiconify()
app.lift()
app.attributes("-topmost", True)
for _index in range(20):
app.update()
time.sleep(0.04)
captured = []
try:
for scheme in ("dark", "hybrid", "light"):
app._apply_theme(scheme)
for view_name, _label, _icon in app._nav_items:
app._show_view(view_name)
app.update_idletasks()
app.update()
time.sleep(0.08)
left = app.winfo_rootx()
top = app.winfo_rooty()
target = output_dir / f"{view_name}-{scheme}.png"
ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target)
captured.append(target)
finally:
app.attributes("-topmost", False)
app._shutting_down = True
app.destroy()
return captured
if __name__ == "__main__":
destination = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("artifacts/mockup-redesign")
for screenshot in capture_all(destination):
print(screenshot)

View file

@ -1,65 +0,0 @@
"""Мастер подключения обязан закрываться по кнопке «Завершить».
Дефект, ради которого написан файл: `_finish` вызывал
`EventLogService.log_event` метода, которого у сервиса нет. Под `pythonw`
консоли нет, трейсбек Tk уходил в никуда, и для пользователя кнопка
«Завершить подключение» просто не работала: окно оставалось открытым,
аккаунт в маршрутизацию не попадал.
"""
from __future__ import annotations
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
@pytest.fixture(scope="module")
def ui_root(tk_root):
app = ctk.CTkToplevel(tk_root)
app.withdraw()
yield app
try:
app.destroy()
except Exception:
pass
def _wizard(root, on_complete=None):
w = AddAccountWizard(root, on_complete=on_complete)
w.withdraw()
w.selected_provider = "antigravity"
w.target_slot = "ag-w2"
w.discovered_identity = "user@example.com"
return w
@pytest.mark.ui
def test_finish_closes_wizard_and_reports_result(ui_root):
seen = []
w = _wizard(ui_root, on_complete=seen.append)
w._finish()
assert w.winfo_exists() == 0, "окно мастера осталось открытым после «Завершить»"
assert seen == [
{"provider": "antigravity", "profile_id": "ag-w2", "identity": "user@example.com"}
]
@pytest.mark.ui
def test_finish_closes_even_if_callback_raises(ui_root):
"""Сбой в обработчике владельца не должен запирать пользователя в мастере."""
def boom(_result):
raise RuntimeError("обновление данных упало")
w = _wizard(ui_root, on_complete=boom)
w._finish()
assert w.winfo_exists() == 0

View file

@ -32,20 +32,18 @@ from antigravity_provider.router.router_config import load_router_config
def test_profile_view_model_mapping():
"""Verify that scan_all creates ProfileViewModels for all configured profiles."""
service = UnifiedHealthService.get()
profiles_by_prov = service.scan_all()
profiles_by_prov = service.scan_all(force=True)
assert "antigravity" in profiles_by_prov
assert "openai-codex" in profiles_by_prov
assert "opencode-go" in profiles_by_prov
all_profs = [p for profs in profiles_by_prov.values() for p in profs]
assert len(all_profs) >= 16
for p in all_profs:
assert isinstance(p, ProfileViewModel)
assert p.profile_id
assert p.display_name
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local")
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm")
assert p.health_state in (
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"
@ -55,7 +53,7 @@ def test_profile_view_model_mapping():
def test_auth_required_never_healthy_without_credentials():
"""Verify that unauthenticated profiles are NEVER marked as HEALTHY."""
service = UnifiedHealthService.get()
profiles_by_prov = service.scan_all()
profiles_by_prov = service.scan_all(force=True)
for profs in profiles_by_prov.values():
for p in profs:
@ -65,8 +63,9 @@ def test_auth_required_never_healthy_without_credentials():
def test_cold_spare_presentation():
"""Verify that cold spare slots are marked as cold_spare or auth_required, not active."""
AutoAssigner.ensure_profile_definition("antigravity", "ag-cold-1")
service = UnifiedHealthService.get()
profiles_by_prov = service.scan_all()
profiles_by_prov = service.scan_all(force=True)
ag_profs = {p.profile_id: p for p in profiles_by_prov["antigravity"]}
cold_1 = ag_profs.get("ag-cold-1")

View file

@ -1,72 +0,0 @@
"""Startup contract for UI views — runs without customtkinter.
Guards the crash that took the app down on launch:
TeamView.__init__ forwarded its legacy ``app_state`` dict into
``update_data(snapshot)``. The guard there only handled ``None``, so ``{}``
slipped through and ``snapshot.readiness`` raised
``'dict' object has no attribute 'readiness'``. "Команда" is the default
view, so the failure happened before the window ever appeared.
These checks are static: they read the view sources rather than build widgets,
so they run in headless environments where the GUI toolkit is absent.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
VIEWS_DIR = Path(__file__).resolve().parent.parent / "src" / "antigravity_provider" / "router" / "ui" / "views"
def _view_files() -> list[Path]:
return sorted(VIEWS_DIR.glob("*_view.py"))
def _self_update_data_calls(tree: ast.AST, inside: str) -> list[ast.Call]:
"""Return self.update_data(...) calls made from the named method."""
calls: list[ast.Call] = []
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
for fn in (n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == inside):
for node in ast.walk(fn):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "update_data"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
):
calls.append(node)
return calls
@pytest.mark.unit
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
def test_constructor_does_not_forward_app_state_to_update_data(view_path: Path) -> None:
"""A view constructor must not pass its legacy app_state into update_data."""
tree = ast.parse(view_path.read_text(encoding="utf-8"))
for call in _self_update_data_calls(tree, inside="__init__"):
assert not call.args and not call.keywords, (
f"{view_path.name}: __init__ calls self.update_data(...) with an argument. "
"update_data expects a HubSnapshot; constructors hold app_state dicts. "
"Call self.update_data() and let it pull the current snapshot."
)
@pytest.mark.unit
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
def test_update_data_guard_rejects_non_snapshot(view_path: Path) -> None:
"""update_data must fall back on anything that is not a HubSnapshot, not just None."""
source = view_path.read_text(encoding="utf-8")
if "def update_data" not in source or "snapshot" not in source:
pytest.skip("view has no snapshot-driven update_data")
# Views are pure renderers now: they either fall back to the store or return
# early. Either way the guard must reject anything that is not a snapshot —
# `snapshot is None` alone lets a legacy app_state dict through, which is
# what crashed the app on launch.
assert "isinstance(snapshot, HubSnapshot)" in source, (
f"{view_path.name}: update_data does not guard with isinstance(snapshot, HubSnapshot). "
"A legacy dict would pass a `snapshot is None` check and then fail on attribute access."
)

View file

@ -0,0 +1,315 @@
/**
* Test contract for Hermes Hub Web Client app.js handlers.
* Tests that all dangling handlers are defined, can be called in a simulated browser environment,
* and that startup execution (DOMContentLoaded checkUpdates) does not throw ReferenceError.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const assert = require('assert');
const appJsPath = path.resolve(__dirname, '../src/antigravity_provider/router/web/static/app.js');
const appJsCode = fs.readFileSync(appJsPath, 'utf8');
const executedActions = [];
function createMockElement(id = '', tag = 'div') {
const el = {
id,
tagName: tag.toUpperCase(),
value: '',
textContent: '',
innerText: '',
innerHTML: '',
className: '',
disabled: false,
dataset: {},
classList: {
_classes: new Set(),
add: function (...cls) { cls.forEach(c => this._classes.add(c)); },
remove: function (...cls) { cls.forEach(c => this._classes.delete(c)); },
toggle: function (cls, force) {
if (force !== undefined) {
if (force) this._classes.add(cls); else this._classes.delete(cls);
} else {
if (this._classes.has(cls)) this._classes.delete(cls); else this._classes.add(cls);
}
},
contains: function (cls) { return this._classes.has(cls); },
},
style: {},
setAttribute: () => {},
removeAttribute: () => {},
getAttribute: () => null,
addEventListener: () => {},
removeEventListener: () => {},
appendChild: (child) => child,
removeChild: (child) => child,
remove: () => {},
focus: () => {},
querySelectorAll: () => [],
querySelector: () => null,
};
return el;
}
const elementsMap = new Map();
function getOrCreateElement(id, tag = 'div') {
if (!elementsMap.has(id)) {
elementsMap.set(id, createMockElement(id, tag));
}
return elementsMap.get(id);
}
const mockDoc = {
addEventListener: (event, cb) => {
if (event === 'DOMContentLoaded') {
mockDoc._domContentLoadedCb = cb;
}
},
getElementById: (id) => getOrCreateElement(id),
querySelector: (sel) => getOrCreateElement(sel.replace(/^[#.]/, '')),
querySelectorAll: (sel) => [getOrCreateElement(sel.replace(/^[#.]/, ''))],
createElement: (tag) => createMockElement('', tag),
body: getOrCreateElement('body'),
location: { search: '', protocol: 'http:', hostname: '127.0.0.1' },
};
const mockWindow = {
// P0-1: expose elementsMap so app.js can call elementsMap.clear() on openAddAccountWizard
elementsMap,
location: { search: '', protocol: 'http:', hostname: '127.0.0.1' },
document: mockDoc,
localStorage: {
_data: {},
getItem: (k) => mockWindow.localStorage._data[k] || '',
setItem: (k, v) => { mockWindow.localStorage._data[k] = String(v); },
removeItem: (k) => { delete mockWindow.localStorage._data[k]; },
},
setInterval: (fn) => setTimeout(fn, 100000),
clearInterval: (id) => clearTimeout(id),
setTimeout: (fn, ms) => {
// If setTimeout with 0 or small ms, don't block
return setTimeout(fn, ms);
},
clearTimeout: (id) => clearTimeout(id),
fetch: async (url, opts = {}) => {
if (url.includes('/api/action')) {
const payload = JSON.parse(opts.body || '{}');
executedActions.push(payload);
const act = payload.action;
if (act === 'start_device_auth') {
return {
ok: true,
status: 200,
json: async () => ({
ok: true,
message: 'Код устройства получен',
data: { session_id: 'sess-dev-1', url: 'https://grok.com/device', code: 'GRK-1234', profile_id: 'grok-1' }
}),
};
}
if (act === 'start_redirect_auth') {
return {
ok: true,
status: 200,
json: async () => ({
ok: true,
message: 'Ссылка получена',
data: { session_id: 'sess-redir-1', url: 'https://accounts.google.com/o/oauth2/auth?...', port: 5801, profile_id: 'ag-w1', provider: payload.data?.provider || 'antigravity' }
}),
};
}
if (act === 'check_updates') {
return {
ok: true,
status: 200,
json: async () => ({
ok: true,
message: 'Проверка обновлений завершена',
data: { update_available: true, current_version: '0.1.1', latest_commit: 'abcdef123456', installed_commit: '1234567890ab' }
}),
};
}
return {
ok: true,
status: 200,
json: async () => ({ ok: true, message: 'Action executed', data: {} }),
};
}
if (url.includes('/api/snapshot')) {
return {
ok: true,
status: 200,
json: async () => ({
seq: 1,
all_profiles: {
'ag-w1': { profile_id: 'ag-w1', provider: 'antigravity', display_name: 'Antigravity 1', preferred_models: ['gemini-2.5-pro'] }
},
routing: {
'coder-primary': { role_name_ru: 'Основной кодер', nodes: [{ profile_id: 'ag-w1', provider: 'antigravity' }] }
},
providers: [
{ provider_id: 'antigravity', provider_name: 'Google Antigravity', discovered_models: ['gemini-2.5-pro', 'gemini-2.5-flash'] }
],
agents: [],
settings: {},
}),
};
}
return {
ok: true,
status: 200,
json: async () => ({}),
};
},
console: {
log: () => {},
warn: () => {},
error: () => {},
info: () => {},
},
URLSearchParams: class {
constructor(str) { this.str = str; }
get() { return null; }
},
};
mockWindow.window = mockWindow;
mockWindow.document = mockDoc;
mockDoc.defaultView = mockWindow;
const sandbox = {
...mockWindow,
global: mockWindow,
globalThis: mockWindow,
};
// P0-1: expose elementsMap in sandbox so app.js can call elementsMap.clear()
sandbox.elementsMap = elementsMap;
vm.createContext(sandbox);
try {
vm.runInContext(appJsCode, sandbox);
} catch (err) {
console.error('FAILED TO EVALUATE app.js in sandbox:', err);
process.exit(1);
}
const requiredHandlers = [
'openAddAccountWizard',
'handleNodeAccountChange',
'handleNodeModelChange',
'handleRefreshProviderModels',
'checkUpdates',
];
for (const handler of requiredHandlers) {
if (typeof sandbox[handler] !== 'function') {
console.error(`RED ERROR: ${handler} is NOT defined as a function in app.js!`);
process.exit(1);
}
}
async function runTests() {
console.log('1. Testing DOMContentLoaded startup without ReferenceError...');
assert(typeof mockDoc._domContentLoadedCb === 'function', 'DOMContentLoaded listener was not registered');
mockDoc._domContentLoadedCb();
console.log('2. Testing checkUpdates handler execution...');
await sandbox.checkUpdates(true);
const checkAction = executedActions.find(a => a.action === 'check_updates');
assert(checkAction, 'checkUpdates did not trigger check_updates action');
console.log('3. Testing openAddAccountWizard & Step 1...');
sandbox.openAddAccountWizard();
const modalBody = getOrCreateElement('modal-body');
assert(modalBody.innerHTML.includes('Шаг 1 из 3'), 'Wizard Step 1 did not render');
console.log('4. Testing showWizardStep2 for grok (device auth flow)...');
sandbox.showWizardStep2('grok');
// GAP-1: grok/codex now shows slot picker first; startDeviceAuth is user-initiated
const deviceSlot = getOrCreateElement('wiz-device-slot');
deviceSlot.value = 'ag-w1';
await sandbox.startDeviceAuth('grok');
const devAuthAction = executedActions.find(a => a.action === 'start_device_auth');
assert(devAuthAction, 'startDeviceAuth(grok) did not trigger start_device_auth action');
// P0-1 BUG-2: startDeviceAuth must send profile_id in payload
assert(
devAuthAction.data && devAuthAction.data.profile_id === 'ag-w1',
`BUG-2: startDeviceAuth must include profile_id='ag-w1' in start_device_auth payload, got: ${JSON.stringify(devAuthAction.data)}`
);
console.log('4b. Testing slot persistence through proceedToWizardStep3 -> finishAddAccount...');
// Reset: fresh wizard, grok flow with owner-selected slot
sandbox.openAddAccountWizard();
sandbox.showWizardStep2('grok');
const slotForStep3 = getOrCreateElement('wiz-device-slot');
slotForStep3.value = 'owner-slot-42';
// proceedToWizardStep3 replaces modalBody (destroying wiz-device-slot)
sandbox.proceedToWizardStep3('grok');
// finishAddAccount reads from destroyed selects — P0-1 BUG-1: profile_id must survive
const targetRoleForStep3 = getOrCreateElement('wiz-target-role');
targetRoleForStep3.value = 'coder-primary';
await sandbox.finishAddAccount('grok');
const addAccFromStep3 = executedActions.find(a => a.action === 'add_account');
assert(addAccFromStep3, 'finishAddAccount did not trigger add_account after step3');
// P0-1 BUG-1: profile_id must be owner-selected slot, NOT empty
assert(
addAccFromStep3.data && addAccFromStep3.data.profile_id === 'owner-slot-42',
`BUG-1: finishAddAccount profile_id must be 'owner-slot-42' (owner choice persisted), got: ${JSON.stringify(addAccFromStep3.data)}`
);
console.log('5. Testing showWizardStep2 for antigravity (redirect auth flow)...');
sandbox.showWizardStep2('antigravity');
const redirectSlot = getOrCreateElement('wiz-redirect-slot');
redirectSlot.value = 'ag-w1';
await sandbox.startRedirectAuth('antigravity');
const redirAuthAction = executedActions.find(a => a.action === 'start_redirect_auth');
assert(redirAuthAction, 'startRedirectAuth(antigravity) did not trigger start_redirect_auth action');
console.log('6. Testing showWizardStep2 for local and finishAddAccount...');
// Reset wizard state (including elementsMap) before local flow to prevent stale slot elements
sandbox.openAddAccountWizard();
sandbox.showWizardStep2('local');
const baseInput = getOrCreateElement('wiz-base-url-input');
baseInput.value = 'http://127.0.0.1:8081/v1';
sandbox.proceedToWizardStep3('local');
const targetRole = getOrCreateElement('wiz-target-role');
targetRole.value = 'coder-primary';
await sandbox.finishAddAccount('local');
// Use [...].reverse().find() to get the LAST add_account action (test 6's, not test 4b's)
const addAccAction = [...executedActions].reverse().find(a => a.action === 'add_account');
assert(addAccAction, 'finishAddAccount did not trigger add_account action');
assert.strictEqual(addAccAction.data.base_url, 'http://127.0.0.1:8081/v1');
// P0-1: local flow has no slot selector, profile_id should be empty/undefined
assert(
!addAccAction.data.profile_id,
`BUG-1: local flow should not have a device slot profile_id, got: ${JSON.stringify(addAccAction.data)}`
);
console.log('7. Testing handleNodeAccountChange...');
await sandbox.handleNodeAccountChange('coder-primary', 'ag-w1', true);
const assignRoleAction = executedActions.find(a => a.action === 'assign_role');
assert(assignRoleAction, 'handleNodeAccountChange did not trigger assign_role action');
assert.strictEqual(assignRoleAction.data.profile_id, 'ag-w1');
console.log('8. Testing handleNodeModelChange...');
await sandbox.handleNodeModelChange('coder-primary', 'ag-w1', 'gemini-2.5-flash');
const setModelAction = executedActions.find(a => a.action === 'set_model');
assert(setModelAction, 'handleNodeModelChange did not trigger set_model action');
assert.strictEqual(setModelAction.data.model, 'gemini-2.5-flash');
console.log('9. Testing handleRefreshProviderModels...');
await sandbox.handleRefreshProviderModels('antigravity');
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
assert(refreshModelsAction, 'handleRefreshProviderModels did not trigger refresh_models action');
console.log('\nAll targeted Node.js DOM and contract test assertions PASSED successfully!');
process.exit(0);
}
runTests().catch(err => {
console.error('\nTEST FAILURE:', err);
process.exit(1);
});

132
uv.lock
View file

@ -241,28 +241,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "customtkinter"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "darkdetect" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/36/aee6ed9171a5a232da66a8b257752bd03d25b487c9d67ba367ad159bd976/customtkinter-6.0.0.tar.gz", hash = "sha256:c782df167bc64ab3fc3140286df06967ac2de4dcc97dd5958426fe9c1a98057b", size = 267195, upload-time = "2026-06-24T20:14:32.144Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/63/e2d3387432c5a94dec64031c86b5ba737216de7e7490abb55efd64dec054/customtkinter-6.0.0-py3-none-any.whl", hash = "sha256:704fcbf1d2fca06bd7881b0afacfae72aed9864c9b6de2870e632e55eb3a0456", size = 302166, upload-time = "2026-06-24T20:14:30.191Z" },
]
[[package]]
name = "darkdetect"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/77/7575be73bf12dee231d0c6e60ce7fb7a7be4fcd58823374fc59a6e48262e/darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1", size = 7681, upload-time = "2022-12-16T14:14:42.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85", size = 8955, upload-time = "2022-12-16T14:14:40.92Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@ -322,14 +300,14 @@ name = "hermes-hub"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "customtkinter" },
{ name = "fastapi" },
{ name = "gguf" },
{ name = "httpx" },
{ name = "pillow" },
{ name = "psutil" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
@ -340,19 +318,13 @@ dev = [
{ name = "pytest-timeout" },
{ name = "ruff" },
]
web = [
{ name = "fastapi" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "anyio", marker = "extra == 'dev'", specifier = ">=4.0.0" },
{ name = "customtkinter", specifier = ">=6.0.0" },
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.110.0" },
{ name = "fastapi", specifier = ">=0.110.0" },
{ name = "gguf", specifier = ">=0.19.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "psutil", specifier = ">=5.9.0" },
{ name = "pydantic", specifier = ">=2.6.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
@ -361,9 +333,9 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.1" },
{ name = "requests", specifier = ">=2.31.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" },
{ name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.28.0" },
{ name = "uvicorn", specifier = ">=0.28.0" },
]
provides-extras = ["dev", "web"]
provides-extras = ["dev"]
[[package]]
name = "httpcore"
@ -643,100 +615,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"