fix(a54): validate accounts synchronously and manage Windows runtime lifecycle
This commit is contained in:
parent
f0d06e4994
commit
ddeba2db0e
31 changed files with 1387 additions and 245 deletions
169
agents/inbox/A54-accounts-fix.md
Normal file
169
agents/inbox/A54-accounts-fix.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Задание A54: проверка аккаунтов не работает, окна консоли, закрытие программы
|
||||||
|
|
||||||
|
## Дата поступления
|
||||||
|
2026-08-31
|
||||||
|
|
||||||
|
## База
|
||||||
|
|
||||||
|
`origin/main` (`f0d06e4`).
|
||||||
|
|
||||||
|
```
|
||||||
|
git fetch origin --prune
|
||||||
|
git checkout -b antigravity/a54-accounts-fix origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
В `main` напрямую не пушить.
|
||||||
|
|
||||||
|
## Порядок исполнения
|
||||||
|
|
||||||
|
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
|
||||||
|
|
||||||
|
**Задание срочное.** После установки сборки `f0d06e4` владелец не может пользоваться программой: ни один аккаунт не проверяется, модели не подтягиваются, поверх окна выскакивают чёрные консоли.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Владелец, дословно: «я так понял ни один аккаунт не подключается. Все проверки проходят с ошибкой, модели перестали нормально подтягиваться. Даже на локальных моделях». И отдельно: «в чём сложность-то?» — про OpenRouter и NVIDIA, которые не подключаются третье задание подряд.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что проверено ревьюером — заново не выяснять
|
||||||
|
|
||||||
|
### Кнопка «Проверить подключение» ничего не проверяет
|
||||||
|
|
||||||
|
Воспроизведено вызовом:
|
||||||
|
|
||||||
|
```
|
||||||
|
check_account profile_id=local-1
|
||||||
|
→ ok=False
|
||||||
|
→ «Фоновая служба проверки не запущена. Перезапустите веб-сервер.»
|
||||||
|
```
|
||||||
|
|
||||||
|
Действие **перекладывает работу на фоновую службу** вместо того, чтобы выполнить проверку. Если служба не поднялась, владелец получает отказ на каждом аккаунте. В интерфейсе это выглядит как «Тест завершился с ошибкой» и «Отказ выполнения действия» — второе вообще запасной текст на случай пустого сообщения, то есть причина до владельца не доходит.
|
||||||
|
|
||||||
|
Служба включается в `web/server.py:496` внутри фонового потока. Любой сбой этого потока оставляет все проверки нерабочими, и узнать об этом нельзя.
|
||||||
|
|
||||||
|
### Удаление аккаунта занимает полминуты
|
||||||
|
|
||||||
|
Причина найдена: `_rescan_after_auth()` вызывает
|
||||||
|
|
||||||
|
```python
|
||||||
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
AccountProbeService.get().schedule_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
то есть **принудительный полный пересбор всех провайдеров** с сетевыми запросами. Таймауты в сборщике квот — 15, 20 и 30 секунд, у Antigravity через CLI — 60. Удаление одного ключа ждёт опроса всех.
|
||||||
|
|
||||||
|
### Окна консоли
|
||||||
|
|
||||||
|
В `f0d06e4` скрытие окна добавлено к двум живым запускам `agy` и к остальным фоновым вызовам. Проверено, что `hidden_process_kwargs()` на Windows возвращает `CREATE_NO_WINDOW` и `SW_HIDE`.
|
||||||
|
|
||||||
|
Окна у владельца остались. Наиболее вероятная причина: **старый процесс сервера пережил обновление**. Закрытие окна браузера сервер не останавливает, и после установки продолжает работать прежний код. Проверить это первым делом.
|
||||||
|
|
||||||
|
`launch_native_agy_login` с `CREATE_NEW_CONSOLE` — мёртвый код, его никто не вызывает. Либо удалить, либо подключить к входу.
|
||||||
|
|
||||||
|
### OpenRouter и NVIDIA
|
||||||
|
|
||||||
|
Сохранение работает — проверено вызовом `add_account`, профиль создаётся с верным идентификатором, чужой слот отклоняется. Значит дело не в сохранении, а в том, что **после ввода ключа ничего не проверяется и модели не запрашиваются**, и владелец остаётся с пустым списком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-1. Проверка выполняется, а не делегируется
|
||||||
|
|
||||||
|
1. **«Проверить подключение» делает настоящий запрос к провайдеру здесь и сейчас** и возвращает результат. Фоновая служба — для периодической проверки, а не для ручной.
|
||||||
|
2. **Отказ невозможен из-за незапущенной службы.** Если фоновая служба нужна, но не работает, ручная проверка всё равно обязана отработать.
|
||||||
|
3. **Причина доходит до владельца.** Запасной текст «Отказ выполнения действия» означает пустое сообщение — таких путей быть не должно.
|
||||||
|
4. **Состояние службы видно** в «Состоянии системы»: работает или нет, когда был последний обход.
|
||||||
|
|
||||||
|
## P0-2. Удаление и очистка
|
||||||
|
|
||||||
|
1. **Удаление ключа не запускает полный пересбор.** Обновлять только затронутый профиль; полный обход — в фон, не блокируя ответ.
|
||||||
|
2. **Кнопка «Очистить все аккаунты»** с подтверждением и перечислением того, что будет удалено.
|
||||||
|
3. **Учётные данные Antigravity — под защитой A37.** Массовое удаление не должно затрагивать `~/.hermes/agy_profiles/` без явного отдельного подтверждения: повторный вход в два десятка аккаунтов делается вручную и стоит владельцу часов.
|
||||||
|
|
||||||
|
## P0-3. Закрытие программы на Windows
|
||||||
|
|
||||||
|
Владелец: «при нажатии на крестик спрашивать, закрыть программу или свернуть в фон. При закрытии полностью всё закрывает».
|
||||||
|
|
||||||
|
1. **Диалог при закрытии**: закрыть полностью или свернуть в фон.
|
||||||
|
2. **Закрытие останавливает всё**: веб-сервер, фоновые опросы, дочерние процессы. После этого окон появляться не должно.
|
||||||
|
3. **Свёрнутое состояние видно** — значок в области уведомлений с пунктами «Открыть» и «Выход».
|
||||||
|
4. **Обновление не должно оставлять старый процесс**: перед установкой прежний сервер останавливается. Это вероятная причина того, что окна не исчезли после установки исправления.
|
||||||
|
|
||||||
|
## P0-4. Подключение по ключу проверяется сразу
|
||||||
|
|
||||||
|
Для `openrouter`, `nvidia`, `nvidia-nim` и прочих провайдеров с ключом:
|
||||||
|
|
||||||
|
1. **После ввода ключа — немедленная проверка**: запрос к провайдеру, и его ответ показывается владельцу.
|
||||||
|
2. **Ключ неверен — сказать сразу**, не создавая профиль-пустышку.
|
||||||
|
3. **Ключ верен — тут же запросить модели** и дать выбрать предпочитаемую в том же окне.
|
||||||
|
4. **Не «сохранено», а «подключено и проверено»** — сообщение должно отражать, что именно произошло.
|
||||||
|
|
||||||
|
## P0-5. Модели у локальных и Ollama
|
||||||
|
|
||||||
|
1. **«Запросить список моделей» у локального профиля возвращает ошибку** — разобраться и починить. Локальный путь не требует ключа, отказ там означает дефект, а не отсутствие доступа.
|
||||||
|
2. **Ollama: список не грузится.** Локальные модели через `/api/tags` по адресу профиля; облачный каталог уже работает — не сломать.
|
||||||
|
3. **Отличать «сервер не отвечает» от «моделей нет»**: у владельца на Windows Ollama не запущена, и `WinError 10061` — это честный ответ, его надо показывать именно так, а не как ошибку обновления.
|
||||||
|
|
||||||
|
## P0-6. Antigravity: было 14 моделей, стало 3
|
||||||
|
|
||||||
|
На прошлой сборке у аккаунтов Antigravity значилось «Получено 14 моделей» с перечнем. Сейчас в карточке три, статус «Не проверялся», а проверка завершается ошибкой.
|
||||||
|
|
||||||
|
1. **Найти, где список сузился.** Проверить, не подменяется ли обнаруженный список предпочтениями профиля — эта ошибка уже была в инспекторе агента и чинилась в правках ревьюера.
|
||||||
|
2. **Число и время получения показывать** рядом со списком, как было.
|
||||||
|
|
||||||
|
## P0-7. Проверка исполнением
|
||||||
|
|
||||||
|
Тестов недостаточно: все перечисленные дефекты прошли через зелёный прогон.
|
||||||
|
|
||||||
|
1. **Открыть хаб и нажать «Проверить подключение»** на локальном профиле, на Ollama и на Antigravity. Результат приложить скриншотами.
|
||||||
|
2. **Подключить OpenRouter с заведомо неверным ключом** и убедиться, что ошибка видна сразу; затем убедиться, что при верном ключе подтягиваются модели.
|
||||||
|
3. **Удалить аккаунт и замерить время** — должно быть быстро, без ожидания опроса всех провайдеров.
|
||||||
|
4. **Закрыть программу крестиком**, выбрать «закрыть», и убедиться, что процессов не осталось и окна не появляются.
|
||||||
|
5. **Проверить, что после обновления старый процесс не остаётся.**
|
||||||
|
|
||||||
|
## P0-8. Аудит вторым проходом
|
||||||
|
|
||||||
|
1. **Проверить, что ручная проверка работает при остановленной фоновой службе.**
|
||||||
|
2. **Искать оставшиеся пути с пустым сообщением об ошибке** — их не должно быть.
|
||||||
|
3. **Замерить удаление аккаунта** независимо.
|
||||||
|
4. **Проверить, что массовая очистка не трогает `~/.hermes/agy_profiles/`.**
|
||||||
|
5. **Проверить, что окна консоли не появляются** при работающей автопроверке.
|
||||||
|
6. **Побочные изменения** объяснить.
|
||||||
|
7. **Пропущенный пункт назвать пропущенным.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Учётные данные и `~/.hermes/agy_profiles/` не трогать; массовое удаление — только с отдельным подтверждением.
|
||||||
|
- Автоматическую проверку не отключать ради тишины: чинить, а не убирать.
|
||||||
|
- Вёрстку A48 не ломать.
|
||||||
|
- Версию `0.1.1` не поднимать.
|
||||||
|
- Правило честности без исключений: причина отказа доходит до владельца текстом.
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
1. Ветка в `origin`, `git status` чист.
|
||||||
|
2. Ручная проверка выполняет запрос и возвращает результат даже при незапущенной фоновой службе; проверено.
|
||||||
|
3. Путей с пустым сообщением об ошибке не осталось.
|
||||||
|
4. Удаление аккаунта не ждёт полного обхода провайдеров; время замерено до и после.
|
||||||
|
5. Есть кнопка очистки всех аккаунтов с подтверждением; учётные данные Antigravity не затрагиваются без отдельного согласия.
|
||||||
|
6. Крестик спрашивает «закрыть или свернуть»; закрытие останавливает сервер и фоновые опросы; проверено отсутствием процессов.
|
||||||
|
7. Обновление не оставляет старый процесс.
|
||||||
|
8. Ввод ключа сразу проверяется, модели подтягиваются в том же окне.
|
||||||
|
9. Список моделей у локального профиля и Ollama работает; «сервер не отвечает» отличается от «моделей нет».
|
||||||
|
10. У Antigravity список моделей вернулся к полному; показано число и время получения.
|
||||||
|
11. Скриншоты проверок приложены.
|
||||||
|
12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **574**.
|
||||||
|
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||||
|
|
||||||
|
## Главное
|
||||||
|
|
||||||
|
Владелец поставил сборку и не может ей пользоваться: проверка отказывает на каждом аккаунте, модели не грузятся даже у локальных, удаление ключа занимает полминуты, а поверх окна выскакивают консоли.
|
||||||
|
|
||||||
|
Общее у большинства этих дефектов одно: **действие не делает работу само, а перекладывает её на фоновую службу или на полный обход всех провайдеров**. Отсюда и отказы, и задержки. Чинить надо это, а не симптомы.
|
||||||
|
|
||||||
|
## Порядок сдачи
|
||||||
|
Передать точный `FINAL_COMMIT_SHA`.
|
||||||
40
agents/reports/a54/README.md
Normal file
40
agents/reports/a54/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# A54 — проверки аккаунтов и завершение приложения
|
||||||
|
|
||||||
|
Дата: 2026-08-31. START_HEAD / origin/main на старте: `f0d06e499449564b3fb19c80a8bcd862ea895594`.
|
||||||
|
Ветка: `antigravity/a54-accounts-fix`. Версия остаётся 0.1.1. Точный FINAL_COMMIT_SHA будет указан при сдаче и в PR после окончательной проверки.
|
||||||
|
|
||||||
|
## Что изменено
|
||||||
|
|
||||||
|
- Ручная проверка выполняет запрос независимо от фонового планировщика; результаты и ошибки возвращаются сразу. Проверки одного профиля сериализованы, незавершённая inference после таймаута не запускается повторно.
|
||||||
|
- Модели запрашиваются отдельным действием без inference. Пустой каталог отличается от ошибки соединения. Облачный каталог Ollama сохранён.
|
||||||
|
- Новый ключ проверяется до создания профиля. OpenRouter: аутентифицированный `/key`, затем каталог. NVIDIA: каталог и минимальный запрос обнаруженной чат-модели. Ошибки не записывают профиль. В мастере можно выбрать полученную модель, результат сохраняется в состоянии проверки.
|
||||||
|
- Удаление и смена модели используют локальные изменения снапшота; подключение не ожидает общего опроса. Массовая очистка показывает точный список, требует подтверждения, отклоняет устаревший список, исключает Antigravity и ссылки на защищённые данные.
|
||||||
|
- AG: явный запрос каталога больше не возвращает пожизненный глобальный кэш другого профиля. Предпочтения подписаны отдельно; каталог не обрезается до восьми, видны число и время. Падение с 14 до 3 на машине владельца не воспроизведено напрямую: найдены глобальный кэш и отдельная строка предпочтений, оба исправлены без заявления о доказанной единственной причине.
|
||||||
|
- Windows: контроллер с tray «Открыть / Выход», выбор полного завершения при закрытии окна приложения; остановка процессов только этой установки. Установщик/PowerShell останавливают старое дерево перед копированием, сохраняя ветвь самого установщика; при обновлении установщик отвечает за перезапуск. Мёртвый запуск отдельной консоли AG удалён. Вывод сервера читается постоянно, чтобы перенаполненный pipe не останавливал сервер.
|
||||||
|
|
||||||
|
## Проверки и их пределы
|
||||||
|
|
||||||
|
- Linux: **598 passed / 1 skipped / 0 failed**, 4 deselected. Пропуск — Windows C# compiler отсутствует. `ruff check .`, Node DOM contracts и `node --check` успешны.
|
||||||
|
- Router verification **10/10**; release gate успешен. Итоговый Windows CI проверяется через draft PR, результат будет дописан после выполнения.
|
||||||
|
- Браузер: настоящий запрос к Qwen на локальном 8081; AG — явно синтетический профиль с 14 моделями; Ollama — HTTP стенд с пустым `/api/tags` и заведомо недоступный порт. Кнопки ручной проверки нажаты.
|
||||||
|
- OpenRouter в браузере: немедленный HTTP 401 при неверном тестовом ключе, успешный HTTP стенд возвращает каталог и выбор модели в том же мастере. Настоящий ключ OpenRouter/NVIDIA владельца не использовался.
|
||||||
|
- Удаление, 50 образцов обработчика: базовая медиана **0.329 мс**, A54 **0.324 мс**; максимумы 6.942 / 63.205 мс. Это не доказательство ускорения пользовательского сценария: полуминутную задержку Windows воспроизвести здесь нельзя. Лишние полные сканирования в последующих операциях устранены отдельно. См. `deletion-benchmark.json`.
|
||||||
|
- Защита AG, ссылки, устаревший preview, ручная проверка при disabled, непустые ошибки, serialization и сохранение результата проверены тестами в изолированных каталогах.
|
||||||
|
|
||||||
|
## Не подтверждено исполнением
|
||||||
|
|
||||||
|
Windows: диалог крестика, tray, отсутствие оставшихся процессов/консолей и обновление поверх запущенной старой установки требуют интерактивной проверки Windows. Компиляция/CI не заменяют её. В fallback обычного браузера его вкладка не отслеживается как окно приложения; выход доступен через tray.
|
||||||
|
|
||||||
|
Не проверены реальные OAuth/каталог аккаунта Antigravity владельца и действующие ключи OpenRouter/NVIDIA. Учётные данные владельца не читались и не изменялись. A54 нельзя считать полностью принятой до этих проверок.
|
||||||
|
|
||||||
|
## Артефакты
|
||||||
|
|
||||||
|
- `local-live.png` — живой локальный сервер.
|
||||||
|
- `antigravity-fixture.png` — 14 синтетических моделей (не доказательство реального AG).
|
||||||
|
- `ollama-fixture.png` — пустой каталог и отказ соединения.
|
||||||
|
- `openrouter-invalid.png`, `openrouter-valid-fixture.png` — отказ и каталог HTTP стенда.
|
||||||
|
- `service-health.png` — состояние периодической службы.
|
||||||
|
- `tests/manual/a54_preview.py` — воспроизводимый изолированный стенд.
|
||||||
|
- `local-model-review.md`, `local-model-usage.json` — честный результат локальной делегации.
|
||||||
|
|
||||||
|
Проверенные первичные описания API: [OpenRouter current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key), [NVIDIA LLM API](https://docs.api.nvidia.com/nim/reference/llm-apis).
|
||||||
BIN
agents/reports/a54/antigravity-fixture.png
Normal file
BIN
agents/reports/a54/antigravity-fixture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
12
agents/reports/a54/deletion-benchmark.json
Normal file
12
agents/reports/a54/deletion-benchmark.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"baseline_f0d06e4": {
|
||||||
|
"median_ms": 0.329,
|
||||||
|
"max_ms": 6.942,
|
||||||
|
"samples": 50
|
||||||
|
},
|
||||||
|
"a54": {
|
||||||
|
"median_ms": 0.324,
|
||||||
|
"max_ms": 63.205,
|
||||||
|
"samples": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
agents/reports/a54/local-live.png
Normal file
BIN
agents/reports/a54/local-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
13
agents/reports/a54/local-model-review.md
Normal file
13
agents/reports/a54/local-model-review.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# A54 — локальная оркестрация
|
||||||
|
|
||||||
|
Использованы HTTP chat completions на 8082 (Qwen3-4B-Instruct-2507) и 8081 (Qwen3-Coder-30B-A3B), последовательно, без передачи данных владельца. Изолированный Git worktree; ответы моделей не исполнялись автоматически.
|
||||||
|
|
||||||
|
Циклы: probe-coder → probe-review → rework → review → rework; дополнительная передача сильному кодеру; preflight-coder → review → rework; Windows helper → review; отдельная генерация теста и ревью. Объёмы и время: `local-model-usage.json` (только сохранённые ответы; один потерянный ответ из-за ошибки оркестрационного скрипта не включён).
|
||||||
|
|
||||||
|
## Итог аудита Codex
|
||||||
|
|
||||||
|
Модели не довели критические части до приемлемого состояния самостоятельно. 4B повторно оставляла отказ при `enabled=False`, использовала несуществующий `threading.ThreadPoolExecutor`, неправильно читала JSON Ollama. 30B тоже предлагала фиктивные профили и успешную проверку без вызова inference. Эти версии отклонены; конечный код существенно переработан Codex.
|
||||||
|
|
||||||
|
Ревью 30B полезно для поиска отдельных дефектов, но содержит ложные срабатывания. Например, оно объявляло нестабильным `setdefault` словаря блокировок под mutex и не признало отсутствие нужного импорта в предложенном тесте. Его `PASS` не принимался как достаточное основание.
|
||||||
|
|
||||||
|
Исправленная схема на этой задаче: локальные кандидаты → статическая проверка Codex → исправления → детерминированные тесты → браузерное исполнение → Windows CI. Нельзя утверждать, что расходы Codex снизились: контрольного замера без делегации нет.
|
||||||
198
agents/reports/a54/local-model-usage.json
Normal file
198
agents/reports/a54/local-model-usage.json
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"step": "preflight-coder",
|
||||||
|
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
"seconds": 13.08,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 1490,
|
||||||
|
"prompt_tokens": 394,
|
||||||
|
"total_tokens": 1884,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "preflight-review",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 5.61,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 333,
|
||||||
|
"prompt_tokens": 1536,
|
||||||
|
"total_tokens": 1869,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "preflight-rework",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 19.48,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 1705,
|
||||||
|
"prompt_tokens": 1849,
|
||||||
|
"total_tokens": 3554,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-coder-1",
|
||||||
|
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
"seconds": 16.74,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 1823,
|
||||||
|
"prompt_tokens": 1149,
|
||||||
|
"total_tokens": 2972,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-review-1",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 5.89,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 603,
|
||||||
|
"prompt_tokens": 2059,
|
||||||
|
"total_tokens": 2662,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 2058
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-review-2",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 7.71,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 511,
|
||||||
|
"prompt_tokens": 2115,
|
||||||
|
"total_tokens": 2626,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 312
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-rework-1",
|
||||||
|
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
"seconds": 19.38,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 1879,
|
||||||
|
"prompt_tokens": 2643,
|
||||||
|
"total_tokens": 4522,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-rework-2",
|
||||||
|
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
"seconds": 22.35,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 2163,
|
||||||
|
"prompt_tokens": 2607,
|
||||||
|
"total_tokens": 4770,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 291
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-root-audit",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 6.82,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 490,
|
||||||
|
"prompt_tokens": 1488,
|
||||||
|
"total_tokens": 1978,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "probe-strong",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 24.7,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 2082,
|
||||||
|
"prompt_tokens": 2439,
|
||||||
|
"total_tokens": 4521,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "test-coder",
|
||||||
|
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
"seconds": 2.67,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 302,
|
||||||
|
"prompt_tokens": 143,
|
||||||
|
"total_tokens": 445,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "test-review",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 4.22,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 385,
|
||||||
|
"prompt_tokens": 335,
|
||||||
|
"total_tokens": 720,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "windows-coder",
|
||||||
|
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
"seconds": 3.15,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 348,
|
||||||
|
"prompt_tokens": 173,
|
||||||
|
"total_tokens": 521,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "windows-review",
|
||||||
|
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
|
||||||
|
"seconds": 3.52,
|
||||||
|
"usage": {
|
||||||
|
"completion_tokens": 296,
|
||||||
|
"prompt_tokens": 378,
|
||||||
|
"total_tokens": 674,
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
agents/reports/a54/ollama-fixture.png
Normal file
BIN
agents/reports/a54/ollama-fixture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
BIN
agents/reports/a54/openrouter-invalid.png
Normal file
BIN
agents/reports/a54/openrouter-invalid.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
agents/reports/a54/openrouter-valid-fixture.png
Normal file
BIN
agents/reports/a54/openrouter-valid-fixture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
agents/reports/a54/service-health.png
Normal file
BIN
agents/reports/a54/service-health.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
|
|
@ -263,12 +263,40 @@ namespace HermesHubSetup
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
|
||||||
|
public static void StopOwnedRuntime(string home, bool includeLauncher)
|
||||||
|
{
|
||||||
|
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
|
||||||
|
string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " +
|
||||||
|
"$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " +
|
||||||
|
"$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " +
|
||||||
|
"while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " +
|
||||||
|
"function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " +
|
||||||
|
"if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " +
|
||||||
|
"$targets=@($all | Where-Object { " +
|
||||||
|
"($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" +
|
||||||
|
" -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" +
|
||||||
|
(includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") +
|
||||||
|
" }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " +
|
||||||
|
"if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }";
|
||||||
|
ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
|
||||||
|
info.UseShellExecute = false;
|
||||||
|
info.CreateNoWindow = true;
|
||||||
|
info.WindowStyle = ProcessWindowStyle.Hidden;
|
||||||
|
using (Process process = Process.Start(info))
|
||||||
|
{
|
||||||
|
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
|
||||||
|
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
|
public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
|
||||||
{
|
{
|
||||||
if (!IsHermesFound) return 10;
|
if (!IsHermesFound) return 10;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
StopOwnedRuntime(HermesHome, true);
|
||||||
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
|
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
|
||||||
if (!Directory.Exists(TargetInstallDir))
|
if (!Directory.Exists(TargetInstallDir))
|
||||||
{
|
{
|
||||||
|
|
@ -1074,12 +1102,14 @@ namespace HermesHubSetup
|
||||||
Application.SetCompatibleTextRenderingDefault(false);
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
|
||||||
bool isSilent = false;
|
bool isSilent = false;
|
||||||
|
bool restartAfterInstall = false;
|
||||||
bool isUninstall = false;
|
bool isUninstall = false;
|
||||||
bool isRepair = false;
|
bool isRepair = false;
|
||||||
bool purgeUserData = false;
|
bool purgeUserData = false;
|
||||||
|
|
||||||
foreach (string a in args)
|
foreach (string a in args)
|
||||||
{
|
{
|
||||||
|
if (a.Equals("/restart", StringComparison.OrdinalIgnoreCase)) restartAfterInstall = true;
|
||||||
if (a.Equals("/silent", StringComparison.OrdinalIgnoreCase) || a.Equals("/s", StringComparison.OrdinalIgnoreCase) || a.Equals("-s", StringComparison.OrdinalIgnoreCase)) isSilent = true;
|
if (a.Equals("/silent", StringComparison.OrdinalIgnoreCase) || a.Equals("/s", StringComparison.OrdinalIgnoreCase) || a.Equals("-s", StringComparison.OrdinalIgnoreCase)) isSilent = true;
|
||||||
if (a.Equals("/uninstall", StringComparison.OrdinalIgnoreCase) || a.Equals("/u", StringComparison.OrdinalIgnoreCase)) isUninstall = true;
|
if (a.Equals("/uninstall", StringComparison.OrdinalIgnoreCase) || a.Equals("/u", StringComparison.OrdinalIgnoreCase)) isUninstall = true;
|
||||||
if (a.Equals("/repair", StringComparison.OrdinalIgnoreCase) || a.Equals("/r", StringComparison.OrdinalIgnoreCase) || a.Equals("/reinstall", StringComparison.OrdinalIgnoreCase)) isRepair = true;
|
if (a.Equals("/repair", StringComparison.OrdinalIgnoreCase) || a.Equals("/r", StringComparison.OrdinalIgnoreCase) || a.Equals("/reinstall", StringComparison.OrdinalIgnoreCase)) isRepair = true;
|
||||||
|
|
@ -1149,6 +1179,11 @@ namespace HermesHubSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
int code = SetupEngine.PerformInstall(sourceRoot);
|
int code = SetupEngine.PerformInstall(sourceRoot);
|
||||||
|
if (code == 0 && restartAfterInstall)
|
||||||
|
{
|
||||||
|
string launcher = Path.Combine(SetupEngine.TargetInstallDir, "HermesHubWeb.exe");
|
||||||
|
if (File.Exists(launcher)) Process.Start(launcher);
|
||||||
|
}
|
||||||
Console.WriteLine("Silent install result: " + code);
|
Console.WriteLine("Silent install result: " + code);
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ namespace HermesHub
|
||||||
{
|
{
|
||||||
public static class WebLauncher
|
public static class WebLauncher
|
||||||
{
|
{
|
||||||
|
private static Mutex instanceMutex;
|
||||||
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
|
@ -69,9 +71,17 @@ namespace HermesHub
|
||||||
string targetUrl = string.Format("http://{0}:{1}/", host, port);
|
string targetUrl = string.Format("http://{0}:{1}/", host, port);
|
||||||
string healthUrl = string.Format("http://{0}:{1}/api/health", host, port);
|
string healthUrl = string.Format("http://{0}:{1}/api/health", host, port);
|
||||||
|
|
||||||
|
bool firstInstance;
|
||||||
|
instanceMutex = new Mutex(true, "Local\\HermesHubWeb", out firstInstance);
|
||||||
|
if (!firstInstance) { Process.Start(targetUrl); return; }
|
||||||
|
// Adopt no unknown server: stop only a verified process from our installation.
|
||||||
|
try { StopOwnedRuntime(hermesHome, false); }
|
||||||
|
catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error); return; }
|
||||||
|
|
||||||
// 2. Check if server is already running and healthy
|
// 2. Check if server is already running and healthy
|
||||||
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
|
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
|
||||||
Process serverProcess = null;
|
Process serverProcess = null;
|
||||||
|
StringBuilder serverLog = new StringBuilder();
|
||||||
|
|
||||||
if (!serverWasAlreadyRunning)
|
if (!serverWasAlreadyRunning)
|
||||||
{
|
{
|
||||||
|
|
@ -131,6 +141,14 @@ namespace HermesHub
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
serverProcess = Process.Start(serverPsi);
|
serverProcess = Process.Start(serverPsi);
|
||||||
|
DataReceivedEventHandler collect = delegate(object sender, DataReceivedEventArgs item) {
|
||||||
|
if (item.Data == null) return;
|
||||||
|
lock (serverLog) { serverLog.AppendLine(item.Data); if (serverLog.Length > 4000) serverLog.Remove(0, serverLog.Length - 4000); }
|
||||||
|
};
|
||||||
|
serverProcess.ErrorDataReceived += collect;
|
||||||
|
serverProcess.OutputDataReceived += collect;
|
||||||
|
serverProcess.BeginErrorReadLine();
|
||||||
|
serverProcess.BeginOutputReadLine();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -149,12 +167,8 @@ namespace HermesHub
|
||||||
}
|
}
|
||||||
if (serverProcess.HasExited)
|
if (serverProcess.HasExited)
|
||||||
{
|
{
|
||||||
string why = "";
|
string why;
|
||||||
try { why = serverProcess.StandardError.ReadToEnd(); } catch { }
|
lock (serverLog) { why = serverLog.ToString(); }
|
||||||
if (string.IsNullOrEmpty(why))
|
|
||||||
{
|
|
||||||
try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { }
|
|
||||||
}
|
|
||||||
if (why.Length > 1500) why = why.Substring(why.Length - 1500);
|
if (why.Length > 1500) why = why.Substring(why.Length - 1500);
|
||||||
string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
|
string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
|
||||||
if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim();
|
if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim();
|
||||||
|
|
@ -178,7 +192,7 @@ namespace HermesHub
|
||||||
// 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback
|
// 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback
|
||||||
string browserPath = FindChromiumBrowser();
|
string browserPath = FindChromiumBrowser();
|
||||||
Process browserProc = null;
|
Process browserProc = null;
|
||||||
DateTime browserStartedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(browserPath))
|
if (!string.IsNullOrEmpty(browserPath))
|
||||||
{
|
{
|
||||||
|
|
@ -199,7 +213,7 @@ namespace HermesHub
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
browserProc = Process.Start(browserPsi);
|
browserProc = Process.Start(browserPsi);
|
||||||
browserStartedAt = DateTime.UtcNow;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -226,36 +240,113 @@ namespace HermesHub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Server Lifecycle:
|
Application.Run(new HubContext(hermesHome, targetUrl, browserPath, browserProc));
|
||||||
// If the server was started by this launcher session and browser is tracked,
|
instanceMutex.ReleaseMutex();
|
||||||
// wait for browser window to close, then gracefully terminate server process.
|
}
|
||||||
if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null)
|
|
||||||
|
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
|
||||||
|
public static void StopOwnedRuntime(string home, bool includeLauncher)
|
||||||
|
{
|
||||||
|
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
|
||||||
|
string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " +
|
||||||
|
"$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " +
|
||||||
|
"$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " +
|
||||||
|
"while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " +
|
||||||
|
"function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " +
|
||||||
|
"if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " +
|
||||||
|
"$targets=@($all | Where-Object { " +
|
||||||
|
"($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" +
|
||||||
|
" -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" +
|
||||||
|
(includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") +
|
||||||
|
" }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " +
|
||||||
|
"if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }";
|
||||||
|
ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
|
||||||
|
info.UseShellExecute = false;
|
||||||
|
info.CreateNoWindow = true;
|
||||||
|
info.WindowStyle = ProcessWindowStyle.Hidden;
|
||||||
|
using (Process process = Process.Start(info))
|
||||||
{
|
{
|
||||||
try
|
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
|
||||||
{
|
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
|
||||||
browserProc.WaitForExit();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
// Подстраховка: если процесс браузера завершился почти сразу,
|
|
||||||
// это почти наверняка передача окна другому экземпляру, а не
|
|
||||||
// закрытие пользователем. Убивать сервер в этом случае нельзя.
|
|
||||||
if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!serverProcess.HasExited)
|
|
||||||
{
|
|
||||||
serverProcess.Kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class HubContext : ApplicationContext
|
||||||
|
{
|
||||||
|
private readonly NotifyIcon tray;
|
||||||
|
private readonly System.Windows.Forms.Timer timer;
|
||||||
|
private readonly string home, url, browserPath;
|
||||||
|
private Process browser;
|
||||||
|
private bool watching, hadWindow, closing;
|
||||||
|
|
||||||
|
public HubContext(string homePath, string targetUrl, string browserExe, Process browserProcess)
|
||||||
|
{
|
||||||
|
home = homePath; url = targetUrl; browserPath = browserExe; browser = browserProcess;
|
||||||
|
watching = browser != null;
|
||||||
|
tray = new NotifyIcon();
|
||||||
|
tray.Icon = System.Drawing.SystemIcons.Application;
|
||||||
|
tray.Text = "Hermes Hub — работает в фоне";
|
||||||
|
ContextMenuStrip menu = new ContextMenuStrip();
|
||||||
|
menu.Items.Add("Открыть", null, delegate { Open(); });
|
||||||
|
menu.Items.Add("Выход", null, delegate { ExitCompletely(); });
|
||||||
|
tray.ContextMenuStrip = menu;
|
||||||
|
tray.DoubleClick += delegate { Open(); };
|
||||||
|
tray.Visible = true;
|
||||||
|
timer = new System.Windows.Forms.Timer(); timer.Interval = 500;
|
||||||
|
timer.Tick += delegate { WatchWindow(); }; timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WatchWindow()
|
||||||
|
{
|
||||||
|
if (!watching || closing || browser == null) return;
|
||||||
|
bool closed;
|
||||||
|
try {
|
||||||
|
browser.Refresh();
|
||||||
|
if (!browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) hadWindow = true;
|
||||||
|
closed = browser.HasExited || (hadWindow && browser.MainWindowHandle == IntPtr.Zero);
|
||||||
|
} catch { closed = true; }
|
||||||
|
if (!closed) return;
|
||||||
|
watching = false;
|
||||||
|
DialogResult answer = MessageBox.Show("Закрыть Hermes Hub полностью?\n\nДа — остановить сервер и фоновые опросы.\nНет — оставить в фоне (значок в области уведомлений).", "Hermes Hub", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||||
|
if (answer == DialogResult.Yes) ExitCompletely();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Open()
|
||||||
|
{
|
||||||
|
if (closing) return;
|
||||||
|
try {
|
||||||
|
if (browser != null && !browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) {
|
||||||
|
ShowWindow(browser.MainWindowHandle, 9); SetForegroundWindow(browser.MainWindowHandle); return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(browserPath)) { Process.Start(url); return; }
|
||||||
|
ProcessStartInfo info = new ProcessStartInfo(browserPath,
|
||||||
|
"--app=\"" + url + "\" --window-size=1400,900 --user-data-dir=\"" + Path.Combine(home, "web_browser_profile") + "\" --no-first-run --no-default-browser-check");
|
||||||
|
info.UseShellExecute = false;
|
||||||
|
browser = Process.Start(info); watching = true; hadWindow = false;
|
||||||
|
} catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExitCompletely()
|
||||||
|
{
|
||||||
|
if (closing) return;
|
||||||
|
closing = true; timer.Stop();
|
||||||
|
try {
|
||||||
|
if (browser != null && !browser.HasExited) {
|
||||||
|
ProcessStartInfo kill = new ProcessStartInfo("taskkill.exe", "/PID " + browser.Id + " /T /F");
|
||||||
|
kill.UseShellExecute = false; kill.CreateNoWindow = true;
|
||||||
|
using (Process process = Process.Start(kill)) { process.WaitForExit(5000); }
|
||||||
|
}
|
||||||
|
StopOwnedRuntime(home, false);
|
||||||
|
tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
closing = false; timer.Start();
|
||||||
|
MessageBox.Show("Не удалось завершить всё: " + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr handle);
|
||||||
|
[System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr handle, int command);
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsServerHealthy(string url)
|
public static bool IsServerHealthy(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,38 @@ if ([string]::IsNullOrWhiteSpace($TargetDir)) {
|
||||||
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
|
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
|
||||||
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
|
||||||
|
|
||||||
|
# Stop only processes owned by this installation before replacing files.
|
||||||
|
# Preserve our own ancestor branch: an updater may have launched this installer.
|
||||||
|
$hubProcesses = @(Get-CimInstance Win32_Process)
|
||||||
|
$hubProtected = @($PID)
|
||||||
|
$hubCursor = $PID
|
||||||
|
while ($hubCursor) {
|
||||||
|
$hubNode = $hubProcesses | Where-Object ProcessId -eq $hubCursor | Select-Object -First 1
|
||||||
|
if (-not $hubNode) { break }
|
||||||
|
$hubCursor = $hubNode.ParentProcessId
|
||||||
|
if ($hubCursor -in $hubProtected) { break }
|
||||||
|
$hubProtected += $hubCursor
|
||||||
|
}
|
||||||
|
function Stop-HubBranch([int]$ProcessId) {
|
||||||
|
foreach ($child in @($hubProcesses | Where-Object ParentProcessId -eq $ProcessId)) {
|
||||||
|
if ($child.ProcessId -notin $hubProtected) { Stop-HubBranch $child.ProcessId }
|
||||||
|
}
|
||||||
|
if (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) {
|
||||||
|
Stop-Process -Id $ProcessId -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$hubPythonPaths = @($HermesPython, (Join-Path $HermesHome 'hermes-agent\venv\Scripts\pythonw.exe'))
|
||||||
|
$hubLauncherPaths = @((Join-Path $HermesHome 'HermesHubWeb.exe'), (Join-Path $TargetDir 'HermesHubWeb.exe'))
|
||||||
|
$hubBrowserPattern = '--user-data-dir="?' + [regex]::Escape((Join-Path $HermesHome 'web_browser_profile')) + '"?(?:\s|$)'
|
||||||
|
foreach ($hubProcess in $hubProcesses) {
|
||||||
|
if (($hubProcess.ExecutablePath -in $hubPythonPaths -and $hubProcess.CommandLine -match 'hermes_hub_web_entry\.py|antigravity_provider\.router\.web') -or
|
||||||
|
($hubProcess.ExecutablePath -in $hubLauncherPaths) -or
|
||||||
|
($hubProcess.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $hubProcess.CommandLine -match $hubBrowserPattern)) {
|
||||||
|
Stop-HubBranch $hubProcess.ProcessId
|
||||||
|
if (Get-Process -Id $hubProcess.ProcessId -ErrorAction SilentlyContinue) { throw 'Old Hermes Hub process survived. Installation cancelled.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# 4. Copy Application Files to TargetDir
|
# 4. Copy Application Files to TargetDir
|
||||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow
|
Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
||||||
Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
|
Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
|
||||||
"""
|
"""
|
||||||
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP
|
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP
|
||||||
if _AGY_MODEL_CACHE is not None:
|
if profile_id is None and _AGY_MODEL_CACHE:
|
||||||
return dict(_AGY_MODEL_CACHE)
|
return dict(_AGY_MODEL_CACHE)
|
||||||
|
|
||||||
exe = get_agy_exe()
|
exe = get_agy_exe()
|
||||||
|
|
@ -166,23 +166,13 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
||||||
)
|
)
|
||||||
raw = result.stdout.strip()
|
raw = result.stdout.strip()
|
||||||
if not raw or result.returncode != 0:
|
if not raw or result.returncode != 0:
|
||||||
logger.warning("discover_models: agy models failed or returned empty output (rc=%s)", result.returncode)
|
if profile_id:
|
||||||
if _AGY_MODEL_CACHE is None:
|
raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен")
|
||||||
_AGY_MODEL_CACHE = {}
|
return dict(_AGY_MODEL_CACHE or {})
|
||||||
_AGY_EFFORT_MAP = {}
|
except Exception:
|
||||||
return dict(_AGY_MODEL_CACHE)
|
if profile_id:
|
||||||
except subprocess.TimeoutExpired:
|
raise
|
||||||
logger.warning("discover_models: agy models timed out")
|
return dict(_AGY_MODEL_CACHE or {})
|
||||||
if _AGY_MODEL_CACHE is None:
|
|
||||||
_AGY_MODEL_CACHE = {}
|
|
||||||
_AGY_EFFORT_MAP = {}
|
|
||||||
return dict(_AGY_MODEL_CACHE)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("discover_models failed: %s", exc)
|
|
||||||
if _AGY_MODEL_CACHE is None:
|
|
||||||
_AGY_MODEL_CACHE = {}
|
|
||||||
_AGY_EFFORT_MAP = {}
|
|
||||||
return dict(_AGY_MODEL_CACHE)
|
|
||||||
|
|
||||||
models: dict[str, str] = {}
|
models: dict[str, str] = {}
|
||||||
effort_map: dict[str, set[str]] = {}
|
effort_map: dict[str, set[str]] = {}
|
||||||
|
|
@ -223,52 +213,6 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
||||||
return dict(models)
|
return dict(models)
|
||||||
|
|
||||||
|
|
||||||
def launch_native_agy_login(profile_id: str) -> subprocess.Popen:
|
|
||||||
"""Launch agy CLI in a visible interactive terminal window with isolated profile environment.
|
|
||||||
|
|
||||||
A22 Requirement: Native login executed by agy itself within the target profile's isolated directory.
|
|
||||||
Zero interception, zero credential logging.
|
|
||||||
"""
|
|
||||||
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
|
|
||||||
|
|
||||||
profile_dir = get_profile_env_dir(profile_id)
|
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
gemini_dir = profile_dir / ".gemini"
|
|
||||||
gemini_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
exe = get_agy_exe()
|
|
||||||
env = build_safe_subprocess_env(
|
|
||||||
overrides={
|
|
||||||
"USERPROFILE": str(profile_dir),
|
|
||||||
"HOME": str(profile_dir),
|
|
||||||
"HOMEPATH": str(profile_dir),
|
|
||||||
"HOMEDRIVE": str(profile_dir)[:2] if str(profile_dir)[1:2] == ":" else "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if os.name == "nt":
|
|
||||||
# Launch visible console window on Windows
|
|
||||||
return subprocess.Popen(
|
|
||||||
[exe],
|
|
||||||
env=env,
|
|
||||||
creationflags=subprocess.CREATE_NEW_CONSOLE,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Cross-platform fallback (Linux/macOS)
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
terminals = [
|
|
||||||
["x-terminal-emulator", "-e", exe],
|
|
||||||
["gnome-terminal", "--", exe],
|
|
||||||
["xterm", "-e", exe],
|
|
||||||
["konsole", "-e", exe],
|
|
||||||
]
|
|
||||||
for term_cmd in terminals:
|
|
||||||
if shutil.which(term_cmd[0]):
|
|
||||||
return subprocess.Popen(term_cmd, env=env)
|
|
||||||
return subprocess.Popen([exe], env=env)
|
|
||||||
|
|
||||||
|
|
||||||
def check_profile_native_auth_status(profile_id: str) -> tuple[bool, str | None, dict[str, Any] | None]:
|
def check_profile_native_auth_status(profile_id: str) -> tuple[bool, str | None, dict[str, Any] | None]:
|
||||||
"""Check if agy native authentication has completed in profile's directory.
|
"""Check if agy native authentication has completed in profile's directory.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ class AccountProbeService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
self._next_check = 0.0
|
self._next_check = 0.0
|
||||||
|
self.last_tick = None
|
||||||
|
self.error = None
|
||||||
|
self._profile_locks = {}
|
||||||
|
self._closed = False
|
||||||
|
self._cloud_next = 0.0
|
||||||
self._states: dict[str, dict[str, Any]] = {}
|
self._states: dict[str, dict[str, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
|
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
|
||||||
|
|
@ -29,66 +34,134 @@ class AccountProbeService:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._states.get(profile_id, {"state": "never_checked"}))
|
return dict(self._states.get(profile_id, {"state": "never_checked"}))
|
||||||
|
|
||||||
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
|
def _profile_lock(self, profile_id: str):
|
||||||
if not self.enabled:
|
with self._lock:
|
||||||
return False
|
return self._profile_locks.setdefault(profile_id, threading.Lock())
|
||||||
|
|
||||||
|
def _mark_checking(self, provider, profile_id):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
current = self._states.get(profile_id, {})
|
|
||||||
if current.get("state") == "checking":
|
|
||||||
return False
|
|
||||||
if not force and current.get("checked_at") and time.time() - current["checked_at"] < 30:
|
|
||||||
return False
|
|
||||||
self._states[profile_id] = {
|
self._states[profile_id] = {
|
||||||
**current, "state": "checking", "provider": provider,
|
**self._states.get(profile_id, {}), "state": "checking",
|
||||||
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты",
|
"provider": provider, "started_at": time.time(),
|
||||||
|
"message": "Идёт запрос к провайдеру",
|
||||||
}
|
}
|
||||||
self._pool.submit(self._run, provider, profile_id)
|
|
||||||
|
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
|
||||||
|
if not self.enabled or self._closed:
|
||||||
|
return False
|
||||||
|
lock = self._profile_lock(profile_id)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
return False
|
||||||
|
current = self.state(profile_id)
|
||||||
|
if not force and time.time() - current.get("checked_at", 0) < 30:
|
||||||
|
lock.release()
|
||||||
|
return False
|
||||||
|
self._mark_checking(provider, profile_id)
|
||||||
|
try:
|
||||||
|
future = self._pool.submit(self._run, provider, profile_id)
|
||||||
|
def cancelled(done):
|
||||||
|
if done.cancelled():
|
||||||
|
with self._lock:
|
||||||
|
self._states[profile_id] = {"state": "failed", "message": "Проверка отменена при завершении сервера"}
|
||||||
|
lock.release()
|
||||||
|
future.add_done_callback(cancelled)
|
||||||
|
except Exception:
|
||||||
|
with self._lock:
|
||||||
|
self._states[profile_id] = current
|
||||||
|
lock.release()
|
||||||
|
raise
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def check_now(self, provider: str, profile_id: str, models_only: bool = False) -> dict:
|
||||||
|
# Periodic scheduling can be disabled without disabling a manual request.
|
||||||
|
if self._closed:
|
||||||
|
return {"ok": False, "message": "Сервер завершает работу"}
|
||||||
|
lock = self._profile_lock(profile_id)
|
||||||
|
if not lock.acquire(timeout=90):
|
||||||
|
return {"ok": False, "message": "Проверка этого аккаунта ещё выполняется; повторите позже"}
|
||||||
|
try:
|
||||||
|
if self._closed:
|
||||||
|
return {"ok": False, "message": "Сервер завершает работу"}
|
||||||
|
self._mark_checking(provider, profile_id)
|
||||||
|
return self._probe(provider, profile_id, models_only)
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def record_validation(self, provider: str, profile_id: str, result: dict) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._states[profile_id] = {
|
||||||
|
"state": "working", "provider": provider, "checked_at": time.time(),
|
||||||
|
"message": result["message"], "models": result["data"]["models"],
|
||||||
|
"check_kind": "credentials_and_catalog",
|
||||||
|
}
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {"enabled": self.enabled, "last_tick": self.last_tick, "error": self.error}
|
||||||
|
|
||||||
def tick(self, now: Optional[float] = None) -> int:
|
def tick(self, now: Optional[float] = None) -> int:
|
||||||
from .settings_service import get_hub_settings
|
from .settings_service import get_hub_settings
|
||||||
now = time.monotonic() if now is None else now
|
now = time.monotonic() if now is None else now
|
||||||
if not self.enabled or now < self._next_check:
|
if not self.enabled or now < self._next_check:
|
||||||
return 0
|
return 0
|
||||||
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
|
try:
|
||||||
return self.schedule_all(force=True)
|
count = self.schedule_all(force=True)
|
||||||
|
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
|
||||||
|
self.last_tick, self.error = time.time(), None
|
||||||
|
return count
|
||||||
|
except Exception as exc:
|
||||||
|
self.error = str(exc).strip() or type(exc).__name__
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.enabled, self._closed = False, True
|
||||||
|
self._pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
def schedule_all(self, *, force: bool = False) -> int:
|
def schedule_all(self, *, force: bool = False) -> int:
|
||||||
from .profile_manager import ProfileAuthManager
|
from .profile_manager import ProfileAuthManager
|
||||||
from .router_config import load_router_config
|
from .router_config import load_router_config
|
||||||
count = 0
|
count = 0
|
||||||
|
has_ollama = False
|
||||||
for pid, pcfg in load_router_config().profiles.items():
|
for pid, pcfg in load_router_config().profiles.items():
|
||||||
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
|
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
|
||||||
count += int(self.schedule(pcfg.provider, pid, force=force))
|
count += int(self.schedule(pcfg.provider, pid, force=force))
|
||||||
|
has_ollama |= pcfg.provider == "ollama"
|
||||||
|
if self.enabled and has_ollama and time.monotonic() >= self._cloud_next:
|
||||||
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
self._cloud_next = time.monotonic() + 3600
|
||||||
|
self._pool.submit(ModelDiscoveryService.get().discover_ollama_cloud)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def _run(self, provider: str, profile_id: str) -> None:
|
def _run(self, provider: str, profile_id: str) -> None:
|
||||||
|
try:
|
||||||
|
self._probe(provider, profile_id)
|
||||||
|
finally:
|
||||||
|
self._profile_lock(profile_id).release()
|
||||||
|
|
||||||
|
def _probe(self, provider: str, profile_id: str, models_only: bool = False) -> dict:
|
||||||
from .action_handler import do_test_profile
|
from .action_handler import do_test_profile
|
||||||
from .model_discovery_service import ModelDiscoveryService
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
models, meta = None, {}
|
||||||
try:
|
try:
|
||||||
from .state_store import HubStateStore
|
discovery = ModelDiscoveryService.get()
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
models = discovery.discover_models_sync(provider, timeout=65 if provider == "antigravity" else 20, profile_id=profile_id)
|
||||||
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id)
|
meta = discovery.get_models_with_metadata(provider, profile_id)
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
if models is None and meta.get("error"):
|
||||||
if provider == "ollama":
|
success, message = False, meta["error"]
|
||||||
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
|
elif models_only:
|
||||||
if cloud.get("is_stale"):
|
success = models is not None and not meta.get("error")
|
||||||
ModelDiscoveryService.get().discover_ollama_cloud()
|
message = meta.get("error") or (f"Получено моделей: {len(models)}" if success else "Провайдер не вернул каталог моделей")
|
||||||
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
|
else:
|
||||||
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id)
|
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
|
||||||
success = bool(result.get("success"))
|
success = bool(result.get("success"))
|
||||||
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения"
|
message = result.get("response") or result.get("error") or "Провайдер не сообщил причину результата проверки"
|
||||||
state = "working" if success else "failed"
|
state = "working" if success else "failed"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
models, meta, state, message = None, {}, "failed", str(exc)
|
success, state, message = False, "failed", str(exc).strip() or type(exc).__name__
|
||||||
|
record = {
|
||||||
|
"state": state, "provider": provider, "checked_at": time.time(),
|
||||||
|
"message": message, "models": models, "model_error": meta.get("error"),
|
||||||
|
"models_discovered_at": meta.get("discovered_at"), "models_only": models_only,
|
||||||
|
}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._states[profile_id] = {
|
self._states[profile_id] = record
|
||||||
"state": state, "provider": provider, "checked_at": time.time(),
|
return {"ok": success, "message": message, "data": record}
|
||||||
"message": message, "models": models, "model_error": meta.get("error"),
|
|
||||||
"models_discovered_at": meta.get("discovered_at"),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
from .state_store import HubStateStore
|
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ from antigravity_provider import paths
|
||||||
from antigravity_provider.router.adapters import get_adapter
|
from antigravity_provider.router.adapters import get_adapter
|
||||||
|
|
||||||
logger = logging.getLogger('hermes.router.actions')
|
logger = logging.getLogger('hermes.router.actions')
|
||||||
|
_test_locks_guard = threading.Lock()
|
||||||
|
_test_locks: dict[str, Any] = {}
|
||||||
|
|
||||||
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
|
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||||
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
|
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
|
||||||
|
|
@ -55,7 +57,10 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
|
||||||
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
||||||
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
||||||
|
|
||||||
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
|
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
|
||||||
|
if not candidates:
|
||||||
|
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
|
||||||
|
model = next((model for model in pcfg.preferred_models if model in candidates), candidates[0])
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
||||||
|
|
@ -73,14 +78,25 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
|
||||||
result_container = []
|
result_container = []
|
||||||
error_container = []
|
error_container = []
|
||||||
|
|
||||||
|
with _test_locks_guard:
|
||||||
|
invoke_lock = _test_locks.setdefault(profile_id, threading.Lock())
|
||||||
|
if not invoke_lock.acquire(blocking=False):
|
||||||
|
return {'success': False, 'error': 'Предыдущий запрос этого аккаунта ещё не завершился'}
|
||||||
|
|
||||||
def _call_invoke():
|
def _call_invoke():
|
||||||
try:
|
try:
|
||||||
result_container.append(adapter.invoke(pcfg, req))
|
result_container.append(adapter.invoke(pcfg, req))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_container.append(e)
|
error_container.append(e)
|
||||||
|
finally:
|
||||||
|
invoke_lock.release()
|
||||||
|
|
||||||
t = threading.Thread(target=_call_invoke, daemon=True)
|
t = threading.Thread(target=_call_invoke, daemon=True)
|
||||||
t.start()
|
try:
|
||||||
|
t.start()
|
||||||
|
except Exception:
|
||||||
|
invoke_lock.release()
|
||||||
|
raise
|
||||||
t.join(timeout=timeout)
|
t.join(timeout=timeout)
|
||||||
|
|
||||||
el = round(time.time() - t0, 2)
|
el = round(time.time() - t0, 2)
|
||||||
|
|
@ -110,7 +126,7 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
|
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
|
||||||
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
|
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e).strip() or type(e).__name__}
|
||||||
|
|
||||||
def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]:
|
def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]:
|
||||||
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали
|
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали
|
||||||
|
|
@ -125,6 +141,8 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system")
|
||||||
if auth_p.is_file():
|
if auth_p.is_file():
|
||||||
try:
|
try:
|
||||||
auth_p.unlink()
|
auth_p.unlink()
|
||||||
|
from .state_store import HubStateStore
|
||||||
|
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
|
||||||
EventLogService.get().log(
|
EventLogService.get().log(
|
||||||
'account',
|
'account',
|
||||||
f'Учетные данные для {profile_id} удалены.',
|
f'Учетные данные для {profile_id} удалены.',
|
||||||
|
|
@ -281,11 +299,8 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
|
||||||
updated.roles[role_id].default_model = model
|
updated.roles[role_id].default_model = model
|
||||||
|
|
||||||
if save_router_config(updated):
|
if save_router_config(updated):
|
||||||
try:
|
from .state_store import HubStateStore
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
HubStateStore.get().apply_delta_profile_preferences(profile_id, target.preferred_models)
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
EventLogService.get().log(
|
EventLogService.get().log(
|
||||||
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info"
|
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info"
|
||||||
)
|
)
|
||||||
|
|
@ -341,15 +356,19 @@ def do_save_request_options(profile_id: str, request_options: Any) -> Tuple[bool
|
||||||
# Функция объявлена на уровне модуля намеренно: как вложенная она была видна
|
# Функция объявлена на уровне модуля намеренно: как вложенная она была видна
|
||||||
# не всем точкам завершения входа, и device-flow получал NameError внутри
|
# не всем точкам завершения входа, и device-flow получал NameError внутри
|
||||||
# обработки успеха.
|
# обработки успеха.
|
||||||
def _rescan_after_auth() -> None:
|
def _rescan_after_auth(provider=None, profile_id=None) -> None:
|
||||||
try:
|
def refresh():
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
try:
|
||||||
|
from .state_store import HubStateStore
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
from .account_probe_service import AccountProbeService
|
||||||
from .account_probe_service import AccountProbeService
|
if provider and profile_id:
|
||||||
AccountProbeService.get().schedule_all()
|
HubStateStore.get().apply_delta_account_added(provider, profile_id)
|
||||||
except Exception as exc: # пересбор не должен ронять сам вход
|
else:
|
||||||
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
AccountProbeService.get().schedule_all()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Не удалось обновить состояние после входа: %s", exc)
|
||||||
|
threading.Thread(target=refresh, name="auth-state-refresh", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def generate_quotas_export(format: str = "json") -> Any:
|
def generate_quotas_export(format: str = "json") -> Any:
|
||||||
|
|
@ -686,6 +705,10 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': reason, 'data': {'status': status}}
|
return {'ok': False, 'message': reason, 'data': {'status': status}}
|
||||||
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
|
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
|
||||||
|
|
||||||
|
if action == 'validate_connection':
|
||||||
|
from .connection_preflight import validate_connection
|
||||||
|
return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '')
|
||||||
|
|
||||||
# Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
|
# Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
|
||||||
if action == 'add_account':
|
if action == 'add_account':
|
||||||
prov_norm = (prov or data.get('provider') or '').strip().lower()
|
prov_norm = (prov or data.get('provider') or '').strip().lower()
|
||||||
|
|
@ -712,6 +735,17 @@ class ActionExecutor:
|
||||||
token = (data.get('token') or data.get('api_key') or '').strip()
|
token = (data.get('token') or data.get('api_key') or '').strip()
|
||||||
slot = data.get('profile_id')
|
slot = data.get('profile_id')
|
||||||
|
|
||||||
|
if slot:
|
||||||
|
valid, reason = AutoAssigner.validate_slot(prov_norm, slot)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason}
|
||||||
|
validation = None
|
||||||
|
if token or prov_norm in ('local', 'vllm', 'ollama'):
|
||||||
|
from .connection_preflight import validate_connection
|
||||||
|
validation = validate_connection(prov_norm, token, base_url, data.get('preferred_model') or '')
|
||||||
|
if not validation['ok']:
|
||||||
|
return validation
|
||||||
|
base_url = validation['data']['base_url']
|
||||||
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
|
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
|
||||||
|
|
||||||
status = ProfileAuthManager.get_profile_status(prov_norm, slot)
|
status = ProfileAuthManager.get_profile_status(prov_norm, slot)
|
||||||
|
|
@ -789,11 +823,21 @@ class ActionExecutor:
|
||||||
# авторизованного аккаунта не существует: перевод аккаунта в
|
# авторизованного аккаунта не существует: перевод аккаунта в
|
||||||
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
|
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
|
||||||
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||||
_rescan_after_auth()
|
if validation:
|
||||||
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
ModelDiscoveryService.get().remember_models(prov_norm, slot, validation['data']['models'])
|
||||||
|
if data.get('preferred_model'):
|
||||||
|
ok, message = do_set_model(slot, data['preferred_model'])
|
||||||
|
if not ok:
|
||||||
|
return {'ok': False, 'message': message}
|
||||||
|
_rescan_after_auth(prov_norm, slot)
|
||||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
AccountProbeService.get().schedule(prov_norm, slot, force=True)
|
if validation:
|
||||||
check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена'
|
AccountProbeService.get().record_validation(prov_norm, slot, validation)
|
||||||
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}}
|
return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}}
|
||||||
|
result = AccountProbeService.get().check_now(prov_norm, slot)
|
||||||
|
result.setdefault('data', {})['profile_id'] = slot
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
||||||
|
|
||||||
|
|
@ -942,6 +986,29 @@ class ActionExecutor:
|
||||||
res = do_test_profile(prov, pid)
|
res = do_test_profile(prov, pid)
|
||||||
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
||||||
|
|
||||||
|
elif action == 'clear_accounts':
|
||||||
|
from .profile_manager import get_profile_auth_path
|
||||||
|
protected_root = (paths.get_hermes_home() / 'agy_profiles').resolve()
|
||||||
|
targets, protected = [], []
|
||||||
|
for profile_id, profile in load_router_config().profiles.items():
|
||||||
|
auth_path = get_profile_auth_path(profile.provider, profile_id)
|
||||||
|
if profile.provider in ('antigravity', 'google-antigravity', 'agy') or auth_path.is_symlink() or auth_path.resolve().is_relative_to(protected_root):
|
||||||
|
protected.append(profile_id)
|
||||||
|
elif auth_path.is_file():
|
||||||
|
targets.append({'provider': profile.provider, 'profile_id': profile_id})
|
||||||
|
preview = {'targets': targets, 'protected': protected}
|
||||||
|
if not data.get('confirmed'):
|
||||||
|
return {'ok': True, 'message': f'Будут удалены ключи {len(targets)} аккаунтов. Antigravity исключён из очистки.', 'data': preview}
|
||||||
|
# Require the exact displayed list. A newly added account is never silently deleted.
|
||||||
|
if data.get('targets') != targets:
|
||||||
|
return {'ok': False, 'message': 'Список аккаунтов изменился. Повторите предварительный просмотр.', 'data': preview}
|
||||||
|
errors = []
|
||||||
|
for target in targets:
|
||||||
|
ok, message = do_delete_credentials(target['provider'], target['profile_id'], actor=actor)
|
||||||
|
if not ok:
|
||||||
|
errors.append(message)
|
||||||
|
return {'ok': not errors, 'message': '; '.join(errors) if errors else f'Удалены ключи {len(targets)} аккаунтов. Antigravity сохранён.', 'data': preview}
|
||||||
|
|
||||||
elif action == 'delete_credentials':
|
elif action == 'delete_credentials':
|
||||||
dry_run = bool(data.get('dry_run', False))
|
dry_run = bool(data.get('dry_run', False))
|
||||||
confirmed = bool(data.get('confirmed', True))
|
confirmed = bool(data.get('confirmed', True))
|
||||||
|
|
@ -1042,13 +1109,10 @@ class ActionExecutor:
|
||||||
|
|
||||||
elif action == 'check_account':
|
elif action == 'check_account':
|
||||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
if not AccountProbeService.get().enabled:
|
|
||||||
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
|
|
||||||
valid, reason = AutoAssigner.validate_slot(prov, pid)
|
valid, reason = AutoAssigner.validate_slot(prov, pid)
|
||||||
if not valid:
|
if not valid:
|
||||||
return {'ok': False, 'message': reason}
|
return {'ok': False, 'message': reason or 'Неверный профиль'}
|
||||||
started = AccountProbeService.get().schedule(prov, pid, force=True)
|
return AccountProbeService.get().check_now(prov, pid)
|
||||||
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
|
|
||||||
|
|
||||||
elif action == 'check_all_accounts':
|
elif action == 'check_all_accounts':
|
||||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
|
@ -1074,6 +1138,12 @@ class ActionExecutor:
|
||||||
return {'ok': True, 'message': 'Успешно'}
|
return {'ok': True, 'message': 'Успешно'}
|
||||||
|
|
||||||
elif action == 'refresh_models':
|
elif action == 'refresh_models':
|
||||||
|
if pid:
|
||||||
|
from .account_probe_service import AccountProbeService
|
||||||
|
valid, reason = AutoAssigner.validate_slot(prov, pid)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason or 'Неверный профиль'}
|
||||||
|
return AccountProbeService.get().check_now(prov, pid, models_only=True)
|
||||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||||
service = ModelDiscoveryService.get()
|
service = ModelDiscoveryService.get()
|
||||||
if prov:
|
if prov:
|
||||||
|
|
|
||||||
93
src/antigravity_provider/router/connection_preflight.py
Normal file
93
src/antigravity_provider/router/connection_preflight.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Validate supplied credentials before creating any profile or writing auth files."""
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
DEFAULT_URLS = {
|
||||||
|
"openrouter": "https://openrouter.ai/api/v1",
|
||||||
|
"nvidia": "https://integrate.api.nvidia.com/v1",
|
||||||
|
"local": "http://127.0.0.1:8081/v1", "vllm": "http://127.0.0.1:8081/v1",
|
||||||
|
"ollama": "http://127.0.0.1:11434", "claude": "https://api.anthropic.com/v1",
|
||||||
|
"opencode-go": "https://opencode.ai/zen/go/v1", "grok": "https://api.x.ai/v1",
|
||||||
|
"openai-codex": "https://api.openai.com/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
raise ValueError("Перенаправление API запрещено: укажите конечный URL сервера")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_connection(provider, token="", base_url="", preferred_model=""):
|
||||||
|
provider = {"nvidia-nim": "nvidia", "local-llm": "local", "llama.cpp": "local"}.get(provider, provider)
|
||||||
|
try:
|
||||||
|
base_url = (base_url or DEFAULT_URLS.get(provider, "")).rstrip("/")
|
||||||
|
parsed = urlsplit(base_url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError("Укажите HTTP(S) URL сервера без пароля, параметров и фрагмента")
|
||||||
|
if provider not in DEFAULT_URLS:
|
||||||
|
raise ValueError("Для этого провайдера используйте вход через авторизацию")
|
||||||
|
if provider not in ("local", "vllm", "ollama") and not token:
|
||||||
|
raise ValueError("Не указан API-ключ")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if provider == "claude":
|
||||||
|
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
|
||||||
|
elif token:
|
||||||
|
headers["Authorization"] = "Bearer " + token
|
||||||
|
opener = urllib.request.build_opener(_NoRedirect())
|
||||||
|
|
||||||
|
def request(path, body=None):
|
||||||
|
req = urllib.request.Request(base_url + path, headers=headers,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None)
|
||||||
|
with opener.open(req, timeout=20) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
if not isinstance(payload, dict) or payload.get("error"):
|
||||||
|
raise ValueError("Провайдер вернул ошибку или неверный JSON")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
if provider == "openrouter":
|
||||||
|
# The catalog is public: its HTTP 200 is not proof of a valid key.
|
||||||
|
request("/key")
|
||||||
|
if provider == "ollama":
|
||||||
|
base_url = base_url.removesuffix("/v1")
|
||||||
|
payload, field, key = request("/api/tags"), "models", "name"
|
||||||
|
else:
|
||||||
|
payload, field, key = request("/models"), "data", "id"
|
||||||
|
entries = payload.get(field)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise ValueError("Провайдер не вернул список моделей")
|
||||||
|
models = sorted({m[key] for m in entries if isinstance(m, dict) and isinstance(m.get(key), str) and m[key]})
|
||||||
|
if provider == "nvidia":
|
||||||
|
if not models:
|
||||||
|
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
|
||||||
|
if preferred_model and preferred_model not in models:
|
||||||
|
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
|
||||||
|
# NVIDIA also exposes a public catalog. Validate with a real request.
|
||||||
|
chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))]
|
||||||
|
if not preferred_model and not chat_models:
|
||||||
|
raise ValueError("Ключ пока не проверен: в каталоге NVIDIA не найдена чат-модель для теста")
|
||||||
|
result = request("/chat/completions", {"model": preferred_model or chat_models[0],
|
||||||
|
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
|
||||||
|
if not isinstance(result.get("choices"), list) or not result["choices"]:
|
||||||
|
raise ValueError("NVIDIA не вернула результат тестового запроса")
|
||||||
|
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
|
||||||
|
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, urllib.error.HTTPError):
|
||||||
|
reason = exc.reason or 'провайдер отклонил запрос'
|
||||||
|
try:
|
||||||
|
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
|
||||||
|
detail = body.get('error') or body.get('detail')
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
detail = detail.get('message')
|
||||||
|
if isinstance(detail, str) and detail.strip():
|
||||||
|
reason = detail[:500]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
message = f"HTTP {exc.code}: {reason}"
|
||||||
|
else:
|
||||||
|
message = str(exc).strip() or type(exc).__name__
|
||||||
|
if token:
|
||||||
|
message = message.replace(token, "[скрыто]")
|
||||||
|
return {"ok": False, "message": message, "data": {"models": []}}
|
||||||
|
|
@ -76,6 +76,13 @@ class ModelDiscoveryService:
|
||||||
# NON-BLOCKING READ API
|
# NON-BLOCKING READ API
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def remember_models(self, provider: str, profile_id: str, models: list[str]) -> None:
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache[f"{provider.lower()}:{profile_id}"] = {
|
||||||
|
"models": list(models), "discovered_at": time.time(), "error": None,
|
||||||
|
}
|
||||||
|
self._save_cache_to_disk()
|
||||||
|
|
||||||
def get_models(self, provider: str) -> Optional[List[str]]:
|
def get_models(self, provider: str) -> Optional[List[str]]:
|
||||||
"""Return cached models for provider immediately, or None if undiscovered."""
|
"""Return cached models for provider immediately, or None if undiscovered."""
|
||||||
meta = self.get_models_with_metadata(provider)
|
meta = self.get_models_with_metadata(provider)
|
||||||
|
|
@ -203,7 +210,7 @@ class ModelDiscoveryService:
|
||||||
result_holder[0] = models
|
result_holder[0] = models
|
||||||
error_holder[0] = err_msg
|
error_holder[0] = err_msg
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error_holder[0] = str(exc)
|
error_holder[0] = str(exc).strip() or type(exc).__name__
|
||||||
|
|
||||||
worker = threading.Thread(target=_do_probe, daemon=True)
|
worker = threading.Thread(target=_do_probe, daemon=True)
|
||||||
worker.start()
|
worker.start()
|
||||||
|
|
@ -412,7 +419,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
|
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("Codex model query failed on %s: %s", pid, exc)
|
logger.debug("Codex model query failed on %s: %s", pid, exc)
|
||||||
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
|
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
|
||||||
|
|
||||||
|
|
@ -448,7 +455,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
|
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
|
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
|
||||||
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
|
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
|
||||||
|
|
||||||
|
|
@ -484,7 +491,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
|
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
|
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
|
||||||
return None, last_err or "Отсутствуют учетные данные для Grok"
|
return None, last_err or "Отсутствуют учетные данные для Grok"
|
||||||
|
|
||||||
|
|
@ -523,7 +530,7 @@ class ModelDiscoveryService:
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
return None, last_err or "Отсутствуют учетные данные для Claude"
|
return None, last_err or "Отсутствуют учетные данные для Claude"
|
||||||
|
|
||||||
elif prov in ("openrouter",):
|
elif prov in ("openrouter",):
|
||||||
|
|
@ -574,7 +581,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
|
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
|
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
|
||||||
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
|
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
|
||||||
|
|
||||||
|
|
@ -613,7 +620,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
|
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
|
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
|
||||||
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
|
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
|
||||||
|
|
||||||
|
|
@ -655,7 +662,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
|
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
|
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
|
||||||
|
|
||||||
# 2. Try OpenAI-compatible endpoint /v1/models
|
# 2. Try OpenAI-compatible endpoint /v1/models
|
||||||
|
|
@ -675,7 +682,7 @@ class ModelDiscoveryService:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
|
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
|
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
|
||||||
|
|
||||||
return None, last_err or "Не удалось подключиться к серверу Ollama"
|
return None, last_err or "Не удалось подключиться к серверу Ollama"
|
||||||
|
|
@ -708,13 +715,12 @@ class ModelDiscoveryService:
|
||||||
for m in items
|
for m in items
|
||||||
if m
|
if m
|
||||||
]
|
]
|
||||||
if models:
|
return sorted(set(models)), None
|
||||||
return sorted(set(models)), None
|
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
|
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = str(exc)
|
last_err = str(exc).strip() or type(exc).__name__
|
||||||
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
|
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
|
||||||
return None, last_err or "Не удалось подключиться к локальному серверу LLM"
|
return None, last_err or "Не удалось подключиться к локальному серверу LLM"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class HubStateStore:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
self._removed_accounts: set[str] = set()
|
||||||
self._generation: int = 0
|
self._generation: int = 0
|
||||||
self._current_snapshot: Optional[HubSnapshot] = None
|
self._current_snapshot: Optional[HubSnapshot] = None
|
||||||
self._pending_refreshes: Dict[str, float] = {}
|
self._pending_refreshes: Dict[str, float] = {}
|
||||||
|
|
@ -138,6 +139,9 @@ class HubStateStore:
|
||||||
# requests may complete while this build is running.
|
# requests may complete while this build is running.
|
||||||
uh_service = UnifiedHealthService.get()
|
uh_service = UnifiedHealthService.get()
|
||||||
profiles_by_prov = uh_service.scan_all(force=force_scan)
|
profiles_by_prov = uh_service.scan_all(force=force_scan)
|
||||||
|
with self._lock:
|
||||||
|
removed = set(self._removed_accounts)
|
||||||
|
profiles_by_prov = {provider: [p for p in profiles if p.profile_id not in removed] for provider, profiles in profiles_by_prov.items()}
|
||||||
all_profs = {
|
all_profs = {
|
||||||
profile.profile_id: profile
|
profile.profile_id: profile
|
||||||
for profiles in profiles_by_prov.values()
|
for profiles in profiles_by_prov.values()
|
||||||
|
|
@ -386,6 +390,8 @@ class HubStateStore:
|
||||||
profile: ProfileViewModel | str,
|
profile: ProfileViewModel | str,
|
||||||
profile_id: Optional[str] = None,
|
profile_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._removed_accounts.discard(profile_id if isinstance(profile, str) else profile.profile_id)
|
||||||
if isinstance(profile, str):
|
if isinstance(profile, str):
|
||||||
provider = profile
|
provider = profile
|
||||||
if profile_id is None:
|
if profile_id is None:
|
||||||
|
|
@ -410,8 +416,16 @@ class HubStateStore:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_delta_profile_preferences(self, profile_id: str, models: list[str]) -> None:
|
||||||
|
# A configuration change does not require quota/identity network requests.
|
||||||
|
with self._lock:
|
||||||
|
profile = self._current_snapshot.get_profile(profile_id) if self._current_snapshot else None
|
||||||
|
if profile:
|
||||||
|
self._apply_profile_delta(replace(profile, preferred_models=list(models)))
|
||||||
|
|
||||||
def apply_delta_account_removed(self, provider: str, profile_id: str) -> None:
|
def apply_delta_account_removed(self, provider: str, profile_id: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._removed_accounts.add(profile_id)
|
||||||
current = self._current_snapshot or self._build_empty_snapshot()
|
current = self._current_snapshot or self._build_empty_snapshot()
|
||||||
all_profiles = dict(current.all_profiles)
|
all_profiles = dict(current.all_profiles)
|
||||||
all_profiles.pop(profile_id, None)
|
all_profiles.pop(profile_id, None)
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,7 @@ class UnifiedHealthService:
|
||||||
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
|
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
|
||||||
if prov == "ollama":
|
if prov == "ollama":
|
||||||
model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
|
model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
|
||||||
if is_authenticated and pcfg.enabled:
|
if is_authenticated and pcfg.enabled and not check.get("models_only"):
|
||||||
if check.get("state") == "checking":
|
if check.get("state") == "checking":
|
||||||
health_state, health_lbl = "checking", "Проверяется…"
|
health_state, health_lbl = "checking", "Проверяется…"
|
||||||
elif check.get("state") == "failed":
|
elif check.get("state") == "failed":
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,10 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
|
from ..account_probe_service import AccountProbeService
|
||||||
return {
|
return {
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"account_probe": AccountProbeService.get().status(),
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"commit": get_installed_commit(),
|
"commit": get_installed_commit(),
|
||||||
|
|
@ -194,6 +197,28 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
raise HTTPException(status_code=503, detail="Snapshot not ready")
|
raise HTTPException(status_code=503, detail="Snapshot not ready")
|
||||||
|
|
||||||
snap_dict = dataclasses.asdict(snapshot)
|
snap_dict = dataclasses.asdict(snapshot)
|
||||||
|
from ..account_probe_service import AccountProbeService
|
||||||
|
from ..model_discovery_service import ModelDiscoveryService
|
||||||
|
probe, discovery = AccountProbeService.get(), ModelDiscoveryService.get()
|
||||||
|
snap_dict["account_probe"] = probe.status()
|
||||||
|
for profile in snap_dict.get("all_profiles", {}).values():
|
||||||
|
pid, provider = profile["profile_id"], profile["provider"]
|
||||||
|
check = probe.state(pid)
|
||||||
|
profile["connection_check"] = check
|
||||||
|
if profile.get("auth_state") == "AUTHENTICATED" and profile.get("enabled", True) and not check.get("models_only"):
|
||||||
|
if check.get("state") == "working":
|
||||||
|
profile["health_state"], profile["health_label_ru"] = "healthy", "Проверен: работает"
|
||||||
|
elif check.get("state") == "failed":
|
||||||
|
profile["health_state"], profile["health_label_ru"] = "unhealthy", "Проверен: не работает — " + check["message"]
|
||||||
|
elif check.get("state") == "checking":
|
||||||
|
profile["health_state"], profile["health_label_ru"] = "checking", "Проверяется…"
|
||||||
|
profile["model_discovery"] = discovery.get_models_with_metadata(provider, pid)
|
||||||
|
if provider == "ollama":
|
||||||
|
profile["model_discovery"]["cloud"] = discovery.get_models_with_metadata("ollama-cloud-catalog")
|
||||||
|
snap_dict["profiles_by_provider"] = {
|
||||||
|
provider: [snap_dict["all_profiles"].get(p["profile_id"], p) for p in profiles]
|
||||||
|
for provider, profiles in snap_dict.get("profiles_by_provider", {}).items()
|
||||||
|
}
|
||||||
snap_dict = sanitize_snapshot(snap_dict)
|
snap_dict = sanitize_snapshot(snap_dict)
|
||||||
|
|
||||||
server_host = _web_settings().get("web_api_host", "127.0.0.1")
|
server_host = _web_settings().get("web_api_host", "127.0.0.1")
|
||||||
|
|
@ -226,13 +251,17 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
|
||||||
threading.Thread(target=func, name=name, daemon=True).start()
|
threading.Thread(target=func, name=name, daemon=True).start()
|
||||||
|
|
||||||
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
||||||
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
|
try:
|
||||||
|
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
|
||||||
|
except Exception as exc:
|
||||||
|
result = {"ok": False, "message": f"Действие {action} завершилось ошибкой {type(exc).__name__}"}
|
||||||
|
|
||||||
if result.get("unknown"):
|
if result.get("unknown"):
|
||||||
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": result.get("ok", False),
|
"ok": result.get("ok", False),
|
||||||
"message": result.get("message", ""),
|
"message": result.get("message") or ("Действие выполнено" if result.get("ok") else f"Действие {action} не выполнено: обработчик не сообщил причину"),
|
||||||
"data": result.get("data", {})
|
"data": result.get("data", {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,6 +517,7 @@ if _STATIC_DIR.is_dir():
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_SNAPSHOT_REFRESH_SEC = 30
|
_SNAPSHOT_REFRESH_SEC = 30
|
||||||
|
_background_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
def _background_refresh_loop() -> None:
|
def _background_refresh_loop() -> None:
|
||||||
|
|
@ -509,17 +539,18 @@ def _background_refresh_loop() -> None:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Initial background update check skipped: %s", exc)
|
logger.debug("Initial background update check skipped: %s", exc)
|
||||||
|
|
||||||
while True:
|
while not _background_stop.is_set():
|
||||||
try:
|
try:
|
||||||
AccountProbeService.get().tick()
|
AccountProbeService.get().tick()
|
||||||
HubStateStore.get().refresh(force_scan=False)
|
HubStateStore.get().refresh(force_scan=False)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Snapshot refresh failed: %s", exc)
|
logger.warning("Snapshot refresh failed: %s", exc)
|
||||||
time.sleep(_SNAPSHOT_REFRESH_SEC)
|
_background_stop.wait(_SNAPSHOT_REFRESH_SEC)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def _start_background_refresh() -> None:
|
def _start_background_refresh() -> None:
|
||||||
|
_background_stop.clear()
|
||||||
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
|
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
|
||||||
# старт сервера нельзя.
|
# старт сервера нельзя.
|
||||||
threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start()
|
threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start()
|
||||||
|
|
@ -530,3 +561,12 @@ def _start_background_refresh() -> None:
|
||||||
AccountQuotaService.get().start_background_scheduler()
|
AccountQuotaService.get().start_background_scheduler()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Could not start quota scheduler: %s", exc)
|
logger.warning("Could not start quota scheduler: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
def _stop_background_refresh() -> None:
|
||||||
|
from ..account_probe_service import AccountProbeService
|
||||||
|
from ..quota_collector import AccountQuotaService
|
||||||
|
_background_stop.set()
|
||||||
|
AccountProbeService.get().shutdown()
|
||||||
|
AccountQuotaService.get().stop_background_scheduler()
|
||||||
|
|
|
||||||
|
|
@ -702,7 +702,7 @@ function renderAccountsView() {
|
||||||
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
||||||
let totalProfiles = 0;
|
let totalProfiles = 0;
|
||||||
let visibleProfiles = 0;
|
let visibleProfiles = 0;
|
||||||
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button></div>';
|
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button> <button class="btn btn-secondary" onclick="handleClearAccounts()">Очистить все аккаунты</button></div>';
|
||||||
|
|
||||||
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
|
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
|
||||||
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
||||||
|
|
@ -846,7 +846,7 @@ function renderAccountCard(profile) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
|
<div class="account-models"><span>Предпочитаемые:</span>${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
|
||||||
${renderAccountCheck(profile)}
|
${renderAccountCheck(profile)}
|
||||||
${quotaGridHtml}
|
${quotaGridHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -862,8 +862,9 @@ function renderAccountCheck(profile) {
|
||||||
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
|
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
|
||||||
return `<div class="account-check" aria-live="polite">
|
return `<div class="account-check" aria-live="polite">
|
||||||
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
|
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
|
||||||
|
<p>${escapeHtml(check.message || "Подключение ещё не проверялось")}</p>
|
||||||
<p>${escapeHtml(modelStatus)}</p>
|
<p>${escapeHtml(modelStatus)}</p>
|
||||||
<div class="account-models">${models.slice(0, 8).map(modelBrandLabel).join('')}</div>
|
<div class="account-models">${models.map(modelBrandLabel).join('')}</div>
|
||||||
${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
|
${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
|
||||||
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button>
|
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -1516,6 +1517,10 @@ function renderHealthView() {
|
||||||
renderHostResources(resContainer, currentSnapshot.metrics?.host || {});
|
renderHostResources(resContainer, currentSnapshot.metrics?.host || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (banner) {
|
||||||
|
const probe = currentSnapshot.account_probe || {};
|
||||||
|
banner.insertAdjacentHTML('beforeend', `<p class="readiness-banner-desc">Автопроверка: ${probe.enabled ? 'работает' : 'остановлена'}. Последний обход: ${probe.last_tick ? escapeHtml(new Date(probe.last_tick * 1000).toLocaleString()) : 'ещё не выполнялся'}. ${escapeHtml(probe.error || '')}</p>`);
|
||||||
|
}
|
||||||
renderHealthPanels(currentSnapshot);
|
renderHealthPanels(currentSnapshot);
|
||||||
const warningsContainer = document.getElementById('health-warnings-list');
|
const warningsContainer = document.getElementById('health-warnings-list');
|
||||||
if (warningsContainer) {
|
if (warningsContainer) {
|
||||||
|
|
@ -2452,9 +2457,10 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
|
||||||
|
|
||||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||||
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
||||||
const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' });
|
const res = await executeAction('refresh_models', { provider: providerId, profile_id: profileId || '' });
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
showToast('Запрос обновления моделей отправлен', 'success');
|
showToast(res.message, 'success');
|
||||||
|
await fetchSnapshot();
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
setTimeout(() => openAccountDetailsModal(profileId, true), 500);
|
setTimeout(() => openAccountDetailsModal(profileId, true), 500);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2680,12 +2686,14 @@ function openAddAccountWizard() {
|
||||||
window._wiz_redirect_slot_id = undefined;
|
window._wiz_redirect_slot_id = undefined;
|
||||||
window._wiz_base_url = undefined;
|
window._wiz_base_url = undefined;
|
||||||
window._wiz_token = undefined;
|
window._wiz_token = undefined;
|
||||||
|
window._wiz_models = undefined;
|
||||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||||
showWizardStep1();
|
showWizardStep1();
|
||||||
showModal();
|
showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWizardStep1() {
|
function showWizardStep1() {
|
||||||
|
window._wiz_models = undefined;
|
||||||
stopDeviceAuthPolling();
|
stopDeviceAuthPolling();
|
||||||
stopRedirectAuthPolling();
|
stopRedirectAuthPolling();
|
||||||
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined;
|
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined;
|
||||||
|
|
@ -2771,6 +2779,7 @@ function showWizardStep2(providerId) {
|
||||||
window._wiz_device_profile = undefined;
|
window._wiz_device_profile = undefined;
|
||||||
window._wiz_base_url = undefined;
|
window._wiz_base_url = undefined;
|
||||||
window._wiz_token = undefined;
|
window._wiz_token = undefined;
|
||||||
|
window._wiz_models = undefined;
|
||||||
}
|
}
|
||||||
window._wiz_provider = providerId;
|
window._wiz_provider = providerId;
|
||||||
let bodyHtml = '';
|
let bodyHtml = '';
|
||||||
|
|
@ -2932,7 +2941,12 @@ function showWizardStep2(providerId) {
|
||||||
elements.modalFooter.innerHTML = footerHtml;
|
elements.modalFooter.innerHTML = footerHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function proceedToWizardStep3(providerId) {
|
async function proceedToWizardStep3(providerId) {
|
||||||
|
if (window._wiz_validating) return;
|
||||||
|
window._wiz_validating = true;
|
||||||
|
const nextButton = elements.modalFooter?.querySelector('.btn-primary');
|
||||||
|
if (nextButton) nextButton.disabled = true;
|
||||||
|
try {
|
||||||
const baseInput = document.getElementById('wiz-base-url-input');
|
const baseInput = document.getElementById('wiz-base-url-input');
|
||||||
if (baseInput) {
|
if (baseInput) {
|
||||||
window._wiz_base_url = baseInput.value.trim();
|
window._wiz_base_url = baseInput.value.trim();
|
||||||
|
|
@ -2954,7 +2968,21 @@ function proceedToWizardStep3(providerId) {
|
||||||
window._wiz_device_profile = redirectSlot?.value || '';
|
window._wiz_device_profile = redirectSlot?.value || '';
|
||||||
}
|
}
|
||||||
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
|
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
|
||||||
|
if (tokenInput || baseInput) {
|
||||||
|
const feedback = document.getElementById('modal-feedback-area');
|
||||||
|
if (feedback) feedback.textContent = 'Проверка подключения и запрос моделей…';
|
||||||
|
const result = await executeAction('validate_connection', {provider: providerId, token: window._wiz_token || '', base_url: window._wiz_base_url || ''});
|
||||||
|
if (!result?.ok) {
|
||||||
|
if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window._wiz_models = result.data.models;
|
||||||
|
}
|
||||||
showWizardStep3(providerId);
|
showWizardStep3(providerId);
|
||||||
|
} finally {
|
||||||
|
window._wiz_validating = false;
|
||||||
|
if (nextButton) nextButton.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWizardStep3(providerId) {
|
function showWizardStep3(providerId) {
|
||||||
|
|
@ -2988,6 +3016,7 @@ function showWizardStep3(providerId) {
|
||||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||||
Шаг 3 из 3: Назначение роли для нового аккаунта
|
Шаг 3 из 3: Назначение роли для нового аккаунта
|
||||||
</div>
|
</div>
|
||||||
|
${window._wiz_models?.length ? `<label for="wiz-preferred-model">Подключение проверено. Моделей: ${window._wiz_models.length}</label><select class="select-filter" id="wiz-preferred-model">${window._wiz_models.map(model => `<option value="${escapeHtml(model)}">${escapeHtml(model)}</option>`).join('')}</select>` : ''}
|
||||||
<div style="margin-bottom:14px;">
|
<div style="margin-bottom:14px;">
|
||||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
|
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
|
||||||
<select class="select-filter" style="width:100%;" id="wiz-target-role">
|
<select class="select-filter" style="width:100%;" id="wiz-target-role">
|
||||||
|
|
@ -3033,6 +3062,7 @@ async function finishAddAccount(providerId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
target_role: targetRole,
|
target_role: targetRole,
|
||||||
|
preferred_model: document.getElementById('wiz-preferred-model')?.value || '',
|
||||||
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
|
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
|
||||||
profile_id: selectedProfileId,
|
profile_id: selectedProfileId,
|
||||||
};
|
};
|
||||||
|
|
@ -3048,7 +3078,7 @@ async function finishAddAccount(providerId) {
|
||||||
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
|
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
|
||||||
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
|
showToast(res.message, 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3877,3 +3907,15 @@ async function setupMemoryStructure(path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function handleClearAccounts() {
|
||||||
|
const preview = await executeAction('clear_accounts', {});
|
||||||
|
if (!preview?.ok) return;
|
||||||
|
const targets = preview.data.targets;
|
||||||
|
const names = targets.map(item => item.profile_id).join('\n');
|
||||||
|
if (!targets.length) { showToast('Нет аккаунтов для очистки. Antigravity защищён.', 'info'); return; }
|
||||||
|
if (!confirm(`Удалить ключи этих аккаунтов?\n${names}\n\nAntigravity не будет затронут. Повторное подключение остальных аккаунтов потребует ключей.`)) return;
|
||||||
|
const result = await executeAction('clear_accounts', {confirmed: true, targets});
|
||||||
|
showToast(result?.message || 'Нет ответа от сервера', result?.ok ? 'success' : 'error');
|
||||||
|
await fetchSnapshot();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -651,7 +651,7 @@ class UpdateManager:
|
||||||
|
|
||||||
elif chosen_asset_name == "HermesHubSetup.exe":
|
elif chosen_asset_name == "HermesHubSetup.exe":
|
||||||
try:
|
try:
|
||||||
cmd = [str(dest_file), "/silent", "/reinstall"]
|
cmd = [str(dest_file), "/silent", "/reinstall", "/restart"]
|
||||||
creation_flags = 0
|
creation_flags = 0
|
||||||
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||||
creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
|
|
||||||
54
tests/manual/a54_preview.py
Normal file
54
tests/manual/a54_preview.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""A54 isolated UI execution: real local HTTP; synthetic remote credentials only."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
os.environ['HERMES_HOME'] = tempfile.mkdtemp(prefix='a54-ui-')
|
||||||
|
os.environ['HERMES_ROUTER_CONFIG'] = os.path.join(os.environ['HERMES_HOME'], 'router_profiles.yaml')
|
||||||
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.router_config import RouterConfig, save_router_config
|
||||||
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||||
|
from antigravity_provider.router.web import server
|
||||||
|
|
||||||
|
|
||||||
|
class Fixture(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
invalid = self.path.endswith('/key') and self.headers.get('Authorization') != 'Bearer fixture-valid'
|
||||||
|
self.send_response(401 if invalid else 200)
|
||||||
|
self.end_headers()
|
||||||
|
body = {'error': {'message': 'A54 fixture: invalid key'}} if invalid else ({'models': []} if self.path.endswith('/api/tags') else {'data': [{'id': 'fixture-chat'}]})
|
||||||
|
self.wfile.write(json.dumps(body).encode())
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"choices":[{"message":{"content":"fixture OK"}}]}')
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
http = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
|
||||||
|
threading.Thread(target=http.serve_forever, daemon=True).start()
|
||||||
|
print('FIXTURE_BASE_URL=http://127.0.0.1:%s/v1' % http.server_port, flush=True)
|
||||||
|
save_router_config(RouterConfig())
|
||||||
|
for provider, pid, url in [('local', 'local-1', 'http://127.0.0.1:8081/v1'), ('ollama', 'ollama-1', f'http://127.0.0.1:{http.server_port}'), ('ollama', 'ollama-2', 'http://127.0.0.1:1')]:
|
||||||
|
AutoAssigner.ensure_profile_definition(provider, pid)
|
||||||
|
ProfileAuthManager.save_profile_auth(provider, pid, {'base_url': url, 'email': 'A54-STAND-' + pid})
|
||||||
|
# No AG account is used: rendering its 14-model fixture does not verify real OAuth.
|
||||||
|
AutoAssigner.ensure_profile_definition('antigravity', 'ag-w1')
|
||||||
|
ProfileAuthManager.save_profile_auth('antigravity', 'ag-w1', {'auth_method': 'oauth', 'email': 'A54-SYNTHETIC-AG'})
|
||||||
|
original_probe = ModelDiscoveryService.get()._probe_provider
|
||||||
|
ModelDiscoveryService.get()._probe_provider = lambda provider: ([f'gemini-fixture-{i}' for i in range(14)], None) if provider == 'antigravity' else original_probe(provider)
|
||||||
|
from antigravity_provider.router import action_handler
|
||||||
|
original_adapter = action_handler.get_adapter
|
||||||
|
action_handler.get_adapter = lambda provider: SimpleNamespace(invoke=lambda *a: {'choices': [{'message': {'content': 'AG fixture'}}]}) if provider == 'antigravity' else original_adapter(provider)
|
||||||
|
AccountQuotaService.get().fetch_all_configured = lambda **kw: None
|
||||||
|
AccountQuotaService.get().start_background_scheduler = lambda: None
|
||||||
|
server.UpdateManager.check_for_updates = lambda self: SimpleNamespace(error=None, message='A54 стенд', update_available=False, to_dict=lambda: {})
|
||||||
|
server.run_web_server(host='127.0.0.1', port=5804)
|
||||||
|
|
@ -60,6 +60,13 @@ def setup_test_environment(tmp_path, monkeypatch):
|
||||||
cfg = RouterConfig()
|
cfg = RouterConfig()
|
||||||
save_router_config(cfg)
|
save_router_config(cfg)
|
||||||
|
|
||||||
|
# These tests cover persistence/routing; HTTP validation has its own A54 tests.
|
||||||
|
from antigravity_provider.router.connection_preflight import DEFAULT_URLS
|
||||||
|
monkeypatch.setattr("antigravity_provider.router.connection_preflight.validate_connection", lambda provider, token='', base_url='', preferred_model='': {
|
||||||
|
"ok": True, "message": "Подключено и проверено", "data": {"models": ["fixture-model"], "base_url": base_url or DEFAULT_URLS[provider]}})
|
||||||
|
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
|
||||||
|
monkeypatch.setattr("antigravity_provider.router.account_probe_service.AccountProbeService.check_now", lambda *args, **kwargs: {"ok": True, "message": "Проверено", "data": {}})
|
||||||
|
|
||||||
yield hermes_home
|
yield hermes_home
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -111,7 +118,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
assert "сохранён" in res["message"]
|
assert "проверено" in res["message"]
|
||||||
|
|
||||||
# Verify profile created and auth saved
|
# Verify profile created and auth saved
|
||||||
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
||||||
|
|
@ -137,7 +144,7 @@ def test_p0_1_add_account_nvidia_default_base_url():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
assert "сохранён" in res["message"]
|
assert "проверено" in res["message"]
|
||||||
|
|
||||||
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
||||||
assert auth is not None
|
assert auth is not None
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,13 @@ def setup_isolated_env(tmp_path, monkeypatch):
|
||||||
UnifiedHealthService._instance = None
|
UnifiedHealthService._instance = None
|
||||||
HubStateStore._instance = None
|
HubStateStore._instance = None
|
||||||
|
|
||||||
|
# These tests cover persistence/routing; HTTP validation has its own A54 tests.
|
||||||
|
from antigravity_provider.router.connection_preflight import DEFAULT_URLS
|
||||||
|
monkeypatch.setattr("antigravity_provider.router.connection_preflight.validate_connection", lambda provider, token='', base_url='', preferred_model='': {
|
||||||
|
"ok": True, "message": "Подключено и проверено", "data": {"models": ["fixture-model"], "base_url": base_url or DEFAULT_URLS[provider]}})
|
||||||
|
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
|
||||||
|
monkeypatch.setattr("antigravity_provider.router.account_probe_service.AccountProbeService.check_now", lambda *args, **kwargs: {"ok": True, "message": "Проверено", "data": {}})
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"hermes_home": hermes_home,
|
"hermes_home": hermes_home,
|
||||||
"profiles_yaml": profiles_yaml,
|
"profiles_yaml": profiles_yaml,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ def isolated(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(AccountProbeService, "_instance", service)
|
monkeypatch.setattr(AccountProbeService, "_instance", service)
|
||||||
discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json")
|
discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json")
|
||||||
monkeypatch.setattr(ModelDiscoveryService, "_instance", discovery)
|
monkeypatch.setattr(ModelDiscoveryService, "_instance", discovery)
|
||||||
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda: None)
|
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
|
||||||
monkeypatch.setattr("antigravity_provider.router.state_store.HubStateStore.refresh", lambda *a, **kw: None)
|
monkeypatch.setattr("antigravity_provider.router.state_store.HubStateStore.refresh", lambda *a, **kw: None)
|
||||||
ActionExecutor._pending_connections.clear()
|
ActionExecutor._pending_connections.clear()
|
||||||
yield service, discovery
|
yield service, discovery
|
||||||
|
|
@ -65,16 +65,10 @@ def test_invalid_key_real_http_correct_slot(isolated, provider):
|
||||||
"provider": provider, "token": "intentionally-invalid",
|
"provider": provider, "token": "intentionally-invalid",
|
||||||
"base_url": f"http://127.0.0.1:{server.server_port}/v1",
|
"base_url": f"http://127.0.0.1:{server.server_port}/v1",
|
||||||
})
|
})
|
||||||
assert result["ok"]
|
assert not result["ok"]
|
||||||
pid = result["data"]["profile_id"]
|
assert "401" in result["message"]
|
||||||
assert pid.startswith(provider + "-")
|
assert not load_router_config().profiles
|
||||||
assert load_router_config().get_profile(pid).provider == provider
|
|
||||||
probe = do_test_profile(provider, pid)
|
|
||||||
assert not probe["success"]
|
|
||||||
assert "401" in probe["error"] and "Invalid API key" in probe["error"]
|
|
||||||
discovery = isolated[1]
|
|
||||||
assert discovery.discover_models_sync(provider, profile_id=pid) is None
|
|
||||||
assert "Invalid API key" in discovery.get_models_with_metadata(provider, pid)["error"]
|
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
@ -187,13 +181,9 @@ def test_slow_action_does_not_block_web_health(isolated, monkeypatch):
|
||||||
asyncio.run(exercise())
|
asyncio.run(exercise())
|
||||||
|
|
||||||
|
|
||||||
def test_repeated_connection_rejected_while_checking(isolated, monkeypatch):
|
def test_repeated_invalid_connection_does_not_create_profiles(isolated):
|
||||||
service, _ = isolated
|
# Repeated invalid input cannot consume slots or create placeholder credentials.
|
||||||
service.enabled = True
|
payload = {"provider": "nvidia", "token": "invalid", "base_url": "invalid-url"}
|
||||||
monkeypatch.setattr(service._pool, "submit", lambda *args: None)
|
assert not ActionExecutor.execute("add_account", payload)["ok"]
|
||||||
payload = {"provider": "nvidia", "token": "intentionally-invalid-repeated"}
|
assert not ActionExecutor.execute("add_account", payload)["ok"]
|
||||||
first = ActionExecutor.execute("add_account", payload)
|
assert not load_router_config().profiles
|
||||||
second = ActionExecutor.execute("add_account", payload)
|
|
||||||
assert first["ok"] and first["data"]["profile_id"] == "nvidia-1"
|
|
||||||
assert not second["ok"] and "проверяется" in second["message"]
|
|
||||||
assert list(load_router_config().profiles) == ["nvidia-1"]
|
|
||||||
|
|
|
||||||
242
tests/test_accounts_a54.py
Normal file
242
tests/test_accounts_a54.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"""A54: real loopback HTTP, no owner credentials, no inference-service load."""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
|
||||||
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
from antigravity_provider.router.connection_preflight import validate_connection
|
||||||
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager, get_profile_auth_path
|
||||||
|
from antigravity_provider.router.router_config import RouterConfig, load_router_config, save_router_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def services(tmp_path, monkeypatch):
|
||||||
|
save_router_config(RouterConfig())
|
||||||
|
probe = AccountProbeService()
|
||||||
|
discovery = ModelDiscoveryService(tmp_path / 'models.json')
|
||||||
|
monkeypatch.setattr(AccountProbeService, '_instance', probe)
|
||||||
|
monkeypatch.setattr(ModelDiscoveryService, '_instance', discovery)
|
||||||
|
monkeypatch.setattr('antigravity_provider.router.action_handler._rescan_after_auth', lambda *a: None)
|
||||||
|
yield probe, discovery
|
||||||
|
probe.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def http_provider():
|
||||||
|
calls = []
|
||||||
|
class Provider(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
calls.append(self.path)
|
||||||
|
invalid = self.headers.get('Authorization') == 'Bearer bad' and self.path.endswith('/key')
|
||||||
|
body = {'error': {'message': 'Invalid key'}} if invalid else ({'models': []} if self.path.endswith('/api/tags') else {'data': [{'id': 'fixture-chat'}]})
|
||||||
|
self.send_response(401 if invalid else 200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(body).encode())
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
calls.append(self.path)
|
||||||
|
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||||
|
assert body['max_tokens'] == 1
|
||||||
|
valid = self.headers.get('Authorization') == 'Bearer fixture-valid'
|
||||||
|
self.send_response(200 if valid else 401)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({'choices': [{'message': {'content': 'ok'}}]} if valid else {'error': {'message': 'Invalid key'}}).encode())
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Provider)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
yield f'http://127.0.0.1:{server.server_port}/v1', calls
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_check_works_when_periodic_disabled(services, monkeypatch):
|
||||||
|
probe, discovery = services
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(discovery, 'discover_models_sync', lambda *a, **kw: ['fixture'])
|
||||||
|
def invoke(*a, **kw):
|
||||||
|
calls.append(kw)
|
||||||
|
return {'success': True, 'response': 'Provider answered'}
|
||||||
|
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', invoke)
|
||||||
|
assert probe.enabled is False
|
||||||
|
result = probe.check_now('local', 'local-1')
|
||||||
|
assert result['ok'] and result['message'] == 'Provider answered'
|
||||||
|
assert len(calls) == 1 and calls[0]['discovered_models'] == ['fixture']
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_only_empty_is_success_without_inference(services, http_provider, monkeypatch):
|
||||||
|
probe, _ = services
|
||||||
|
url, calls = http_provider
|
||||||
|
AutoAssigner.ensure_profile_definition('ollama', 'ollama-1')
|
||||||
|
ProfileAuthManager.save_profile_auth('ollama', 'ollama-1', {'base_url': url})
|
||||||
|
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', lambda *a, **kw: pytest.fail('catalog called inference'))
|
||||||
|
result = probe.check_now('ollama', 'ollama-1', models_only=True)
|
||||||
|
assert result['ok'] and result['data']['models'] == []
|
||||||
|
assert calls == ['/api/tags']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia'])
|
||||||
|
def test_invalid_key_never_creates_profile(services, http_provider, provider):
|
||||||
|
url, _ = http_provider
|
||||||
|
result = ActionExecutor.execute('add_account', {'provider': provider, 'token': 'bad', 'base_url': url})
|
||||||
|
assert not result['ok'] and '401' in result['message']
|
||||||
|
assert not load_router_config().profiles
|
||||||
|
assert not get_profile_auth_path(provider, provider + '-1').exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia', 'local', 'ollama'])
|
||||||
|
def test_valid_preflight_returns_catalog(services, http_provider, provider):
|
||||||
|
url, calls = http_provider
|
||||||
|
result = validate_connection(provider, 'fixture-valid', url)
|
||||||
|
assert result['ok'] and result['message']
|
||||||
|
assert result['data']['models'] == ([] if provider == 'ollama' else ['fixture-chat'])
|
||||||
|
assert ('/v1/chat/completions' in calls) == (provider == 'nvidia')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', ['bad-url', 'http://user:pass@localhost/v1', 'http://localhost/v1?key=abc', 'file:///tmp/models'])
|
||||||
|
def test_preflight_rejects_invalid_urls(url):
|
||||||
|
result = validate_connection('local', '', url)
|
||||||
|
assert not result['ok'] and result['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_exception_is_visible(services, monkeypatch):
|
||||||
|
probe, discovery = services
|
||||||
|
def fail(*a, **kw):
|
||||||
|
raise RuntimeError()
|
||||||
|
monkeypatch.setattr(discovery, 'discover_models_sync', fail)
|
||||||
|
result = probe.check_now('local', 'local-1')
|
||||||
|
assert not result['ok'] and result['message'] == 'RuntimeError'
|
||||||
|
|
||||||
|
|
||||||
|
def test_periodic_failure_visible_and_retry(services, monkeypatch):
|
||||||
|
probe, _ = services
|
||||||
|
probe.enabled = True
|
||||||
|
monkeypatch.setattr(probe, 'schedule_all', lambda **kw: (_ for _ in ()).throw(ValueError('sweep failed')))
|
||||||
|
assert probe.tick(1) == 0 and probe.status()['error'] == 'sweep failed'
|
||||||
|
monkeypatch.setattr(probe, 'schedule_all', lambda **kw: 3)
|
||||||
|
assert probe.tick(2) == 3
|
||||||
|
assert probe.status()['last_tick'] and probe.status()['error'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_background_do_not_overlap(services, monkeypatch):
|
||||||
|
probe, discovery = services
|
||||||
|
probe.enabled = True
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
concurrent, maximum = 0, 0
|
||||||
|
def discover(*a, **kw):
|
||||||
|
nonlocal concurrent, maximum
|
||||||
|
concurrent += 1
|
||||||
|
maximum = max(maximum, concurrent)
|
||||||
|
entered.set()
|
||||||
|
assert release.wait(3)
|
||||||
|
concurrent -= 1
|
||||||
|
return ['fixture']
|
||||||
|
monkeypatch.setattr(discovery, 'discover_models_sync', discover)
|
||||||
|
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', lambda *a, **kw: {'success': True, 'response': 'ok'})
|
||||||
|
assert probe.schedule('local', 'local-1')
|
||||||
|
assert entered.wait(2)
|
||||||
|
result = []
|
||||||
|
manual = threading.Thread(target=lambda: result.append(probe.check_now('local', 'local-1')))
|
||||||
|
manual.start()
|
||||||
|
assert not probe.schedule('local', 'local-1', force=True)
|
||||||
|
release.set()
|
||||||
|
manual.join(3)
|
||||||
|
assert result[0]['ok'] and maximum == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_fast_and_bulk_protects_antigravity(services, monkeypatch):
|
||||||
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
removed = []
|
||||||
|
monkeypatch.setattr(HubStateStore, 'get', lambda: SimpleNamespace(apply_delta_account_removed=lambda *a: removed.append(a)))
|
||||||
|
for provider, pid in [('antigravity', 'ag-w1'), ('nvidia', 'nvidia-1'), ('local', 'local-1')]:
|
||||||
|
AutoAssigner.ensure_profile_definition(provider, pid)
|
||||||
|
path = get_profile_auth_path(provider, pid)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text('{"fixture":true}')
|
||||||
|
protected = get_profile_auth_path('antigravity', 'ag-w1')
|
||||||
|
before = protected.read_bytes()
|
||||||
|
started = time.monotonic()
|
||||||
|
assert do_delete_credentials('local', 'local-1')[0]
|
||||||
|
assert time.monotonic() - started < 0.5
|
||||||
|
preview = ActionExecutor.execute('clear_accounts', {})
|
||||||
|
assert 'ag-w1' in preview['data']['protected']
|
||||||
|
assert protected.read_bytes() == before
|
||||||
|
result = ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': preview['data']['targets']})
|
||||||
|
assert result['ok'] and protected.read_bytes() == before
|
||||||
|
assert not get_profile_auth_path('nvidia', 'nvidia-1').exists()
|
||||||
|
assert len(removed) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_stale_preview_rejected(services):
|
||||||
|
result = ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': [{'profile_id': 'forged'}]})
|
||||||
|
assert not result['ok'] and 'изменился' in result['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_antigravity_explicit_catalog_ignores_global_cache(monkeypatch):
|
||||||
|
from antigravity_provider import agy_subprocess as agy
|
||||||
|
monkeypatch.setattr(agy, '_AGY_MODEL_CACHE', {'old': 'old'})
|
||||||
|
monkeypatch.setattr(agy, 'get_agy_exe', lambda: 'fixture-agy')
|
||||||
|
monkeypatch.setattr(agy.subprocess, 'run', lambda *a, **kw: SimpleNamespace(returncode=0, stdout='\n'.join(f'gemini-{i}\tfixture' for i in range(14))))
|
||||||
|
assert len(agy.discover_models('ag-w1')) == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_add_remembers_check_and_selected_model(services, http_provider):
|
||||||
|
probe, discovery = services
|
||||||
|
url, _ = http_provider
|
||||||
|
result = ActionExecutor.execute('add_account', {'provider': 'openrouter', 'token': 'fixture-valid', 'base_url': url, 'preferred_model': 'fixture-chat'})
|
||||||
|
assert result['ok']
|
||||||
|
pid = result['data']['profile_id']
|
||||||
|
assert probe.state(pid)['state'] == 'working'
|
||||||
|
assert discovery.get_models_with_metadata('openrouter', pid)['models'] == ['fixture-chat']
|
||||||
|
assert load_router_config().get_profile(pid).preferred_models[0] == 'fixture-chat'
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_keeps_inference_reserved(services, monkeypatch):
|
||||||
|
from antigravity_provider.router.action_handler import do_test_profile
|
||||||
|
AutoAssigner.ensure_profile_definition('local', 'local-1')
|
||||||
|
ProfileAuthManager.save_profile_auth('local', 'local-1', {'base_url': 'http://127.0.0.1:1/v1'})
|
||||||
|
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
|
||||||
|
calls = []
|
||||||
|
def invoke(*a, **kw):
|
||||||
|
calls.append(1)
|
||||||
|
entered.set()
|
||||||
|
try:
|
||||||
|
assert release.wait(2)
|
||||||
|
return {}
|
||||||
|
finally:
|
||||||
|
finished.set()
|
||||||
|
monkeypatch.setattr('antigravity_provider.router.action_handler.get_adapter', lambda p: SimpleNamespace(invoke=invoke))
|
||||||
|
try:
|
||||||
|
first = do_test_profile('local', 'local-1', timeout=0.02, discovered_models=['fixture'])
|
||||||
|
assert entered.is_set() and not first['success']
|
||||||
|
second = do_test_profile('local', 'local-1', timeout=0.02, discovered_models=['fixture'])
|
||||||
|
assert not second['success'] and 'ещё не завершился' in second['error']
|
||||||
|
assert calls == [1]
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
assert finished.wait(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_protects_symlink_into_ag(services, tmp_path):
|
||||||
|
AutoAssigner.ensure_profile_definition('local', 'local-1')
|
||||||
|
target = get_profile_auth_path('antigravity', 'ag-w1')
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text('protected fixture')
|
||||||
|
link = get_profile_auth_path('local', 'local-1')
|
||||||
|
link.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
link.symlink_to(target)
|
||||||
|
preview = ActionExecutor.execute('clear_accounts', {})
|
||||||
|
assert 'local-1' in preview['data']['protected']
|
||||||
|
assert preview['data']['targets'] == []
|
||||||
|
assert ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': []})['ok']
|
||||||
|
assert target.read_text() == 'protected fixture'
|
||||||
|
|
@ -10,17 +10,13 @@ Acceptance criteria verification:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from antigravity_provider.agy_subprocess import (
|
from antigravity_provider.agy_subprocess import (
|
||||||
check_profile_native_auth_status,
|
check_profile_native_auth_status,
|
||||||
launch_native_agy_login,
|
|
||||||
)
|
)
|
||||||
from antigravity_provider.paths import get_profile_dir
|
from antigravity_provider.paths import get_profile_dir
|
||||||
from antigravity_provider.router.action_handler import do_set_model
|
from antigravity_provider.router.action_handler import do_set_model
|
||||||
|
|
@ -106,35 +102,9 @@ def test_profile_operations_never_touch_user_home_gemini(tmp_path, monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch):
|
def test_unused_console_login_removed():
|
||||||
"""P0-2: launch_native_agy_login launches agy in profile's isolated directory."""
|
from antigravity_provider import agy_subprocess
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
assert not hasattr(agy_subprocess, "launch_native_agy_login")
|
||||||
|
|
||||||
slot = "ag-w2"
|
|
||||||
expected_pdir = get_profile_dir(slot, "antigravity")
|
|
||||||
|
|
||||||
with patch("subprocess.Popen") as mock_popen, \
|
|
||||||
patch("antigravity_provider.agy_subprocess.get_agy_exe", return_value="C:\\fake\\agy.exe"):
|
|
||||||
|
|
||||||
mock_popen.return_value = MagicMock()
|
|
||||||
proc = launch_native_agy_login(slot)
|
|
||||||
|
|
||||||
assert proc is not None
|
|
||||||
mock_popen.assert_called_once()
|
|
||||||
args, kwargs = mock_popen.call_args
|
|
||||||
|
|
||||||
# Command includes the mocked agy executable (Linux wraps it in a terminal).
|
|
||||||
launched = args[0]
|
|
||||||
assert "C:\\fake\\agy.exe" in launched
|
|
||||||
|
|
||||||
# Environment points to profile dir
|
|
||||||
env = kwargs.get("env", {})
|
|
||||||
assert env.get("USERPROFILE") == str(expected_pdir)
|
|
||||||
assert env.get("HOME") == str(expected_pdir)
|
|
||||||
assert env.get("HOMEPATH") == str(expected_pdir)
|
|
||||||
|
|
||||||
# Profile .gemini directory was created
|
|
||||||
assert (expected_pdir / ".gemini").is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 3: Detection of native agy authentication ──
|
# ── TEST 3: Detection of native agy authentication ──
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ async function runTests() {
|
||||||
const slotForStep3 = getOrCreateElement('wiz-device-slot');
|
const slotForStep3 = getOrCreateElement('wiz-device-slot');
|
||||||
slotForStep3.value = 'owner-slot-42';
|
slotForStep3.value = 'owner-slot-42';
|
||||||
// proceedToWizardStep3 replaces modalBody (destroying wiz-device-slot)
|
// proceedToWizardStep3 replaces modalBody (destroying wiz-device-slot)
|
||||||
sandbox.proceedToWizardStep3('grok');
|
await sandbox.proceedToWizardStep3('grok');
|
||||||
// finishAddAccount reads from destroyed selects — P0-1 BUG-1: profile_id must survive
|
// finishAddAccount reads from destroyed selects — P0-1 BUG-1: profile_id must survive
|
||||||
const targetRoleForStep3 = getOrCreateElement('wiz-target-role');
|
const targetRoleForStep3 = getOrCreateElement('wiz-target-role');
|
||||||
targetRoleForStep3.value = 'coder-primary';
|
targetRoleForStep3.value = 'coder-primary';
|
||||||
|
|
@ -274,7 +274,7 @@ async function runTests() {
|
||||||
sandbox.showWizardStep2('local');
|
sandbox.showWizardStep2('local');
|
||||||
const baseInput = getOrCreateElement('wiz-base-url-input');
|
const baseInput = getOrCreateElement('wiz-base-url-input');
|
||||||
baseInput.value = 'http://127.0.0.1:8081/v1';
|
baseInput.value = 'http://127.0.0.1:8081/v1';
|
||||||
sandbox.proceedToWizardStep3('local');
|
await sandbox.proceedToWizardStep3('local');
|
||||||
const targetRole = getOrCreateElement('wiz-target-role');
|
const targetRole = getOrCreateElement('wiz-target-role');
|
||||||
targetRole.value = 'coder-primary';
|
targetRole.value = 'coder-primary';
|
||||||
await sandbox.finishAddAccount('local');
|
await sandbox.finishAddAccount('local');
|
||||||
|
|
@ -310,13 +310,13 @@ async function runTests() {
|
||||||
sandbox.openAddAccountWizard();
|
sandbox.openAddAccountWizard();
|
||||||
sandbox.showWizardStep2('antigravity');
|
sandbox.showWizardStep2('antigravity');
|
||||||
getOrCreateElement('wiz-redirect-slot').value = 'ag-w1';
|
getOrCreateElement('wiz-redirect-slot').value = 'ag-w1';
|
||||||
sandbox.proceedToWizardStep3('antigravity');
|
await sandbox.proceedToWizardStep3('antigravity');
|
||||||
assert.strictEqual(sandbox.window._wiz_device_profile, 'ag-w1');
|
assert.strictEqual(sandbox.window._wiz_device_profile, 'ag-w1');
|
||||||
sandbox.showWizardStep1();
|
sandbox.showWizardStep1();
|
||||||
sandbox.showWizardStep2(provider);
|
sandbox.showWizardStep2(provider);
|
||||||
getOrCreateElement('wiz-redirect-slot').value = '';
|
getOrCreateElement('wiz-redirect-slot').value = '';
|
||||||
getOrCreateElement('wiz-token-input').value = 'intentionally-invalid';
|
getOrCreateElement('wiz-token-input').value = 'intentionally-invalid';
|
||||||
sandbox.proceedToWizardStep3(provider);
|
await sandbox.proceedToWizardStep3(provider);
|
||||||
await sandbox.finishAddAccount(provider);
|
await sandbox.finishAddAccount(provider);
|
||||||
const action = [...executedActions].reverse().find(a => a.action === 'add_account');
|
const action = [...executedActions].reverse().find(a => a.action === 'add_account');
|
||||||
assert.strictEqual(action.data.provider, provider);
|
assert.strictEqual(action.data.provider, provider);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue