chore: merge origin/main into antigravity/a44-restore-server
This commit is contained in:
commit
5ab9eed14b
86 changed files with 6635 additions and 10793 deletions
137
agents/inbox/2026-08-25-A32-remove-desktop.md
Normal file
137
agents/inbox/2026-08-25-A32-remove-desktop.md
Normal 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`.
|
||||
177
agents/inbox/2026-08-30-A34-restore-wizard-providers-desktop.md
Normal file
177
agents/inbox/2026-08-30-A34-restore-wizard-providers-desktop.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Задание A34: восстановить подключение аккаунтов, добавить OpenRouter и NVIDIA, удалить десктоп
|
||||
|
||||
## Дата поступления
|
||||
2026-08-30
|
||||
|
||||
## База
|
||||
|
||||
Работать **поверх ветки ревьюера** `review/a28-a31-fixes` (`529192b`), а не поверх `main` и не поверх своей прошлой ветки. В ней уже лежат A28–A31, слитые с `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 блокирует всё остальное: пока нельзя подключить аккаунт, ни новых провайдеров, ни автопоиска проверить не на чем.
|
||||
|
||||
---
|
||||
|
||||
## Что уже сделано ревьюером — не переделывать
|
||||
|
||||
Снято исполнением на кандидате A28–A31 и исправлено в `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 8080–8082, 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`.
|
||||
145
agents/inbox/2026-08-30-A35-hub-must-actually-route-hermes.md
Normal file
145
agents/inbox/2026-08-30-A35-hub-must-actually-route-hermes.md
Normal 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`.
|
||||
144
agents/inbox/2026-08-30-A36-antigravity-pipeline.md
Normal file
144
agents/inbox/2026-08-30-A36-antigravity-pipeline.md
Normal 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`.
|
||||
143
agents/inbox/2026-08-30-A37-agent-isolation-and-guards.md
Normal file
143
agents/inbox/2026-08-30-A37-agent-isolation-and-guards.md
Normal 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`.
|
||||
167
agents/inbox/2026-08-30-A38-local-model-benchmark.md
Normal file
167
agents/inbox/2026-08-30-A38-local-model-benchmark.md
Normal 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 ток/с рабочего.
|
||||
|
||||
Собрать набор из **10–15 настоящих задач** по этому репозиторию, с известным правильным результатом: взять реальные правки из истории 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. Набор из 10–15 настоящих задач составлен; результат по каждой модели приведён.
|
||||
6. Есть замер на длинном контексте.
|
||||
7. Таблица и вывод по каждой модели приложены.
|
||||
8. Конфигурация владельца не изменена.
|
||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`.
|
||||
|
||||
## Главное
|
||||
|
||||
Нынешний кодер выдаёт 13,6 токена в секунду — для интерактивной работы это тяжело. Вопрос не в том, какая модель быстрее на бумаге, а в том, какая быстрее **при том же качестве на настоящих задачах владельца**. Сравнение, измеряющее только скорость, ответа не даст.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
142
agents/inbox/2026-08-31-A39-local-request-options.md
Normal file
142
agents/inbox/2026-08-31-A39-local-request-options.md
Normal 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`.
|
||||
173
agents/inbox/2026-08-31-A40-benchmark-redo.md
Normal file
173
agents/inbox/2026-08-31-A40-benchmark-redo.md
Normal 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`.
|
||||
126
agents/inbox/2026-08-31-A41-clean-first-install.md
Normal file
126
agents/inbox/2026-08-31-A41-clean-first-install.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Задание A41: чистая конфигурация при первой установке
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`380c218`) — там уже слиты A34–A39 и правки ревьюера.
|
||||
|
||||
```
|
||||
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`.
|
||||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 сюда НЕ входят:
|
||||
|
|
@ -665,6 +920,12 @@ class ActionExecutor:
|
|||
elif action == 'save_settings':
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
233
src/antigravity_provider/router/adapters/nvidia_adapter.py
Normal file
233
src/antigravity_provider/router/adapters/nvidia_adapter.py
Normal 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)
|
||||
279
src/antigravity_provider/router/adapters/ollama_adapter.py
Normal file
279
src/antigravity_provider/router/adapters/ollama_adapter.py
Normal 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)
|
||||
268
src/antigravity_provider/router/adapters/openrouter_adapter.py
Normal file
268
src/antigravity_provider/router/adapters/openrouter_adapter.py
Normal 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)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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 ())
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""Hermes Hub UI Views Package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
360
tests/test_a39_local_request_options.py
Normal file
360
tests/test_a39_local_request_options.py
Normal 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}"
|
||||
264
tests/test_a41_clean_install.py
Normal file
264
tests/test_a41_clean_install.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
389
tests/test_api_providers_a32.py
Normal file
389
tests/test_api_providers_a32.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
267
tests/test_openai_compatible_providers_a34.py
Normal file
267
tests/test_openai_compatible_providers_a34.py
Normal 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
|
||||
387
tests/test_p0_1_dangling_handlers_and_wizard.py
Normal file
387
tests/test_p0_1_dangling_handlers_and_wizard.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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])
|
||||
|
|
@ -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:00–21: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()
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
"""Acceptance coverage for the phase 2–6 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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
315
tests/test_web_handlers_dom_contract.js
Normal file
315
tests/test_web_handlers_dom_contract.js
Normal 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
132
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue