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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
if (!IsHermesFound) return 10;
|
||||
|
||||
try
|
||||
{
|
||||
StopOwnedRuntime(HermesHome, true);
|
||||
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
|
||||
if (!Directory.Exists(TargetInstallDir))
|
||||
{
|
||||
|
|
@ -1074,12 +1102,14 @@ namespace HermesHubSetup
|
|||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
bool isSilent = false;
|
||||
bool restartAfterInstall = false;
|
||||
bool isUninstall = false;
|
||||
bool isRepair = false;
|
||||
bool purgeUserData = false;
|
||||
|
||||
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("/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;
|
||||
|
|
@ -1149,6 +1179,11 @@ namespace HermesHubSetup
|
|||
}
|
||||
|
||||
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);
|
||||
return code;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ namespace HermesHub
|
|||
{
|
||||
public static class WebLauncher
|
||||
{
|
||||
private static Mutex instanceMutex;
|
||||
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
|
|
@ -69,9 +71,17 @@ namespace HermesHub
|
|||
string targetUrl = string.Format("http://{0}:{1}/", 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
|
||||
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
|
||||
Process serverProcess = null;
|
||||
StringBuilder serverLog = new StringBuilder();
|
||||
|
||||
if (!serverWasAlreadyRunning)
|
||||
{
|
||||
|
|
@ -131,6 +141,14 @@ namespace HermesHub
|
|||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
|
@ -149,12 +167,8 @@ namespace HermesHub
|
|||
}
|
||||
if (serverProcess.HasExited)
|
||||
{
|
||||
string why = "";
|
||||
try { why = serverProcess.StandardError.ReadToEnd(); } catch { }
|
||||
if (string.IsNullOrEmpty(why))
|
||||
{
|
||||
try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { }
|
||||
}
|
||||
string why;
|
||||
lock (serverLog) { why = serverLog.ToString(); }
|
||||
if (why.Length > 1500) why = why.Substring(why.Length - 1500);
|
||||
string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
|
||||
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
|
||||
string browserPath = FindChromiumBrowser();
|
||||
Process browserProc = null;
|
||||
DateTime browserStartedAt = DateTime.UtcNow;
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(browserPath))
|
||||
{
|
||||
|
|
@ -199,7 +213,7 @@ namespace HermesHub
|
|||
try
|
||||
{
|
||||
browserProc = Process.Start(browserPsi);
|
||||
browserStartedAt = DateTime.UtcNow;
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -226,36 +240,113 @@ namespace HermesHub
|
|||
}
|
||||
}
|
||||
|
||||
// 5. Server Lifecycle:
|
||||
// If the server was started by this launcher session and browser is tracked,
|
||||
// wait for browser window to close, then gracefully terminate server process.
|
||||
if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null)
|
||||
Application.Run(new HubContext(hermesHome, targetUrl, browserPath, browserProc));
|
||||
instanceMutex.ReleaseMutex();
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
browserProc.WaitForExit();
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Подстраховка: если процесс браузера завершился почти сразу,
|
||||
// это почти наверняка передача окна другому экземпляру, а не
|
||||
// закрытие пользователем. Убивать сервер в этом случае нельзя.
|
||||
if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!serverProcess.HasExited)
|
||||
{
|
||||
serverProcess.Kill();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
|
||||
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -50,6 +50,38 @@ if ([string]::IsNullOrWhiteSpace($TargetDir)) {
|
|||
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
|
||||
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
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
exe = get_agy_exe()
|
||||
|
|
@ -166,23 +166,13 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
|||
)
|
||||
raw = result.stdout.strip()
|
||||
if not raw or result.returncode != 0:
|
||||
logger.warning("discover_models: agy models failed or returned empty output (rc=%s)", result.returncode)
|
||||
if _AGY_MODEL_CACHE is None:
|
||||
_AGY_MODEL_CACHE = {}
|
||||
_AGY_EFFORT_MAP = {}
|
||||
return dict(_AGY_MODEL_CACHE)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("discover_models: agy models timed out")
|
||||
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)
|
||||
if profile_id:
|
||||
raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен")
|
||||
return dict(_AGY_MODEL_CACHE or {})
|
||||
except Exception:
|
||||
if profile_id:
|
||||
raise
|
||||
return dict(_AGY_MODEL_CACHE or {})
|
||||
|
||||
models: dict[str, 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)
|
||||
|
||||
|
||||
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]:
|
||||
"""Check if agy native authentication has completed in profile's directory.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ class AccountProbeService:
|
|||
def __init__(self) -> None:
|
||||
self.enabled = False
|
||||
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._lock = threading.Lock()
|
||||
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
|
||||
|
|
@ -29,66 +34,134 @@ class AccountProbeService:
|
|||
with self._lock:
|
||||
return dict(self._states.get(profile_id, {"state": "never_checked"}))
|
||||
|
||||
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
def _profile_lock(self, profile_id: str):
|
||||
with self._lock:
|
||||
return self._profile_locks.setdefault(profile_id, threading.Lock())
|
||||
|
||||
def _mark_checking(self, provider, profile_id):
|
||||
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] = {
|
||||
**current, "state": "checking", "provider": provider,
|
||||
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты",
|
||||
**self._states.get(profile_id, {}), "state": "checking",
|
||||
"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
|
||||
|
||||
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:
|
||||
from .settings_service import get_hub_settings
|
||||
now = time.monotonic() if now is None else now
|
||||
if not self.enabled or now < self._next_check:
|
||||
return 0
|
||||
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
|
||||
return self.schedule_all(force=True)
|
||||
try:
|
||||
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:
|
||||
from .profile_manager import ProfileAuthManager
|
||||
from .router_config import load_router_config
|
||||
count = 0
|
||||
has_ollama = False
|
||||
for pid, pcfg in load_router_config().profiles.items():
|
||||
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
|
||||
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
|
||||
|
||||
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 .model_discovery_service import ModelDiscoveryService
|
||||
models, meta = None, {}
|
||||
try:
|
||||
from .state_store import HubStateStore
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id)
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
if provider == "ollama":
|
||||
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
|
||||
if cloud.get("is_stale"):
|
||||
ModelDiscoveryService.get().discover_ollama_cloud()
|
||||
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
|
||||
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id)
|
||||
success = bool(result.get("success"))
|
||||
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения"
|
||||
discovery = ModelDiscoveryService.get()
|
||||
models = discovery.discover_models_sync(provider, timeout=65 if provider == "antigravity" else 20, profile_id=profile_id)
|
||||
meta = discovery.get_models_with_metadata(provider, profile_id)
|
||||
if models is None and meta.get("error"):
|
||||
success, message = False, meta["error"]
|
||||
elif models_only:
|
||||
success = models is not None and not meta.get("error")
|
||||
message = meta.get("error") or (f"Получено моделей: {len(models)}" if success else "Провайдер не вернул каталог моделей")
|
||||
else:
|
||||
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
|
||||
success = bool(result.get("success"))
|
||||
message = result.get("response") or result.get("error") or "Провайдер не сообщил причину результата проверки"
|
||||
state = "working" if success else "failed"
|
||||
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:
|
||||
self._states[profile_id] = {
|
||||
"state": state, "provider": provider, "checked_at": time.time(),
|
||||
"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
|
||||
self._states[profile_id] = record
|
||||
return {"ok": success, "message": message, "data": record}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from antigravity_provider import paths
|
|||
from antigravity_provider.router.adapters import get_adapter
|
||||
|
||||
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]:
|
||||
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':
|
||||
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()
|
||||
try:
|
||||
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 = []
|
||||
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():
|
||||
try:
|
||||
result_container.append(adapter.invoke(pcfg, req))
|
||||
except Exception as e:
|
||||
error_container.append(e)
|
||||
finally:
|
||||
invoke_lock.release()
|
||||
|
||||
t = threading.Thread(target=_call_invoke, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
t.start()
|
||||
except Exception:
|
||||
invoke_lock.release()
|
||||
raise
|
||||
t.join(timeout=timeout)
|
||||
|
||||
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:
|
||||
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]:
|
||||
# Сигнатура 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():
|
||||
try:
|
||||
auth_p.unlink()
|
||||
from .state_store import HubStateStore
|
||||
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
|
||||
EventLogService.get().log(
|
||||
'account',
|
||||
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
|
||||
|
||||
if save_router_config(updated):
|
||||
try:
|
||||
from antigravity_provider.router.state_store import HubStateStore
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
except Exception:
|
||||
pass
|
||||
from .state_store import HubStateStore
|
||||
HubStateStore.get().apply_delta_profile_preferences(profile_id, target.preferred_models)
|
||||
EventLogService.get().log(
|
||||
"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 внутри
|
||||
# обработки успеха.
|
||||
def _rescan_after_auth() -> None:
|
||||
try:
|
||||
from antigravity_provider.router.state_store import HubStateStore
|
||||
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
from .account_probe_service import AccountProbeService
|
||||
AccountProbeService.get().schedule_all()
|
||||
except Exception as exc: # пересбор не должен ронять сам вход
|
||||
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
|
||||
def _rescan_after_auth(provider=None, profile_id=None) -> None:
|
||||
def refresh():
|
||||
try:
|
||||
from .state_store import HubStateStore
|
||||
from .account_probe_service import AccountProbeService
|
||||
if provider and profile_id:
|
||||
HubStateStore.get().apply_delta_account_added(provider, profile_id)
|
||||
else:
|
||||
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:
|
||||
|
|
@ -686,6 +705,10 @@ class ActionExecutor:
|
|||
return {'ok': False, 'message': reason, '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)
|
||||
if action == 'add_account':
|
||||
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()
|
||||
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'
|
||||
|
||||
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)
|
||||
_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
|
||||
AccountProbeService.get().schedule(prov_norm, slot, force=True)
|
||||
check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена'
|
||||
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}}
|
||||
if validation:
|
||||
AccountProbeService.get().record_validation(prov_norm, slot, validation)
|
||||
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:
|
||||
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
||||
|
||||
|
|
@ -942,6 +986,29 @@ class ActionExecutor:
|
|||
res = do_test_profile(prov, pid)
|
||||
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':
|
||||
dry_run = bool(data.get('dry_run', False))
|
||||
confirmed = bool(data.get('confirmed', True))
|
||||
|
|
@ -1042,13 +1109,10 @@ class ActionExecutor:
|
|||
|
||||
elif action == 'check_account':
|
||||
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)
|
||||
if not valid:
|
||||
return {'ok': False, 'message': reason}
|
||||
started = AccountProbeService.get().schedule(prov, pid, force=True)
|
||||
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
|
||||
return {'ok': False, 'message': reason or 'Неверный профиль'}
|
||||
return AccountProbeService.get().check_now(prov, pid)
|
||||
|
||||
elif action == 'check_all_accounts':
|
||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||
|
|
@ -1074,6 +1138,12 @@ class ActionExecutor:
|
|||
return {'ok': True, 'message': 'Успешно'}
|
||||
|
||||
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
|
||||
service = ModelDiscoveryService.get()
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
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]]:
|
||||
"""Return cached models for provider immediately, or None if undiscovered."""
|
||||
meta = self.get_models_with_metadata(provider)
|
||||
|
|
@ -203,7 +210,7 @@ class ModelDiscoveryService:
|
|||
result_holder[0] = models
|
||||
error_holder[0] = err_msg
|
||||
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.start()
|
||||
|
|
@ -412,7 +419,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
|
||||
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)
|
||||
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
|
||||
|
||||
|
|
@ -448,7 +455,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
|
||||
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)
|
||||
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
|
||||
|
||||
|
|
@ -484,7 +491,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
|
||||
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)
|
||||
return None, last_err or "Отсутствуют учетные данные для Grok"
|
||||
|
||||
|
|
@ -523,7 +530,7 @@ class ModelDiscoveryService:
|
|||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
last_err = str(exc).strip() or type(exc).__name__
|
||||
return None, last_err or "Отсутствуют учетные данные для Claude"
|
||||
|
||||
elif prov in ("openrouter",):
|
||||
|
|
@ -574,7 +581,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
|
||||
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)
|
||||
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
|
||||
|
||||
|
|
@ -613,7 +620,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
|
||||
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)
|
||||
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
|
||||
|
||||
|
|
@ -655,7 +662,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
|
||||
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)
|
||||
|
||||
# 2. Try OpenAI-compatible endpoint /v1/models
|
||||
|
|
@ -675,7 +682,7 @@ class ModelDiscoveryService:
|
|||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
|
||||
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)
|
||||
|
||||
return None, last_err or "Не удалось подключиться к серверу Ollama"
|
||||
|
|
@ -708,13 +715,12 @@ class ModelDiscoveryService:
|
|||
for m in items
|
||||
if m
|
||||
]
|
||||
if models:
|
||||
return sorted(set(models)), None
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as 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)
|
||||
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)
|
||||
return None, last_err or "Не удалось подключиться к локальному серверу LLM"
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class HubStateStore:
|
|||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._removed_accounts: set[str] = set()
|
||||
self._generation: int = 0
|
||||
self._current_snapshot: Optional[HubSnapshot] = None
|
||||
self._pending_refreshes: Dict[str, float] = {}
|
||||
|
|
@ -138,6 +139,9 @@ class HubStateStore:
|
|||
# requests may complete while this build is running.
|
||||
uh_service = UnifiedHealthService.get()
|
||||
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 = {
|
||||
profile.profile_id: profile
|
||||
for profiles in profiles_by_prov.values()
|
||||
|
|
@ -386,6 +390,8 @@ class HubStateStore:
|
|||
profile: ProfileViewModel | str,
|
||||
profile_id: Optional[str] = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._removed_accounts.discard(profile_id if isinstance(profile, str) else profile.profile_id)
|
||||
if isinstance(profile, str):
|
||||
provider = profile
|
||||
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:
|
||||
with self._lock:
|
||||
self._removed_accounts.add(profile_id)
|
||||
current = self._current_snapshot or self._build_empty_snapshot()
|
||||
all_profiles = dict(current.all_profiles)
|
||||
all_profiles.pop(profile_id, None)
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ class UnifiedHealthService:
|
|||
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
|
||||
if prov == "ollama":
|
||||
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":
|
||||
health_state, health_lbl = "checking", "Проверяется…"
|
||||
elif check.get("state") == "failed":
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
|||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
from ..account_probe_service import AccountProbeService
|
||||
return {
|
||||
"pid": os.getpid(),
|
||||
"account_probe": AccountProbeService.get().status(),
|
||||
"ok": True,
|
||||
"version": __version__,
|
||||
"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")
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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"):
|
||||
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
||||
|
||||
return {
|
||||
"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", {})
|
||||
}
|
||||
|
||||
|
|
@ -488,6 +517,7 @@ if _STATIC_DIR.is_dir():
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
_SNAPSHOT_REFRESH_SEC = 30
|
||||
_background_stop = threading.Event()
|
||||
|
||||
|
||||
def _background_refresh_loop() -> None:
|
||||
|
|
@ -509,17 +539,18 @@ def _background_refresh_loop() -> None:
|
|||
except Exception as exc:
|
||||
logger.debug("Initial background update check skipped: %s", exc)
|
||||
|
||||
while True:
|
||||
while not _background_stop.is_set():
|
||||
try:
|
||||
AccountProbeService.get().tick()
|
||||
HubStateStore.get().refresh(force_scan=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Snapshot refresh failed: %s", exc)
|
||||
time.sleep(_SNAPSHOT_REFRESH_SEC)
|
||||
_background_stop.wait(_SNAPSHOT_REFRESH_SEC)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _start_background_refresh() -> None:
|
||||
_background_stop.clear()
|
||||
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
|
||||
# старт сервера нельзя.
|
||||
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()
|
||||
except Exception as 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 || {};
|
||||
let totalProfiles = 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)) {
|
||||
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
||||
|
|
@ -846,7 +846,7 @@ function renderAccountCard(profile) {
|
|||
</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)}
|
||||
${quotaGridHtml}
|
||||
</div>
|
||||
|
|
@ -862,8 +862,9 @@ function renderAccountCheck(profile) {
|
|||
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
|
||||
return `<div class="account-check" aria-live="polite">
|
||||
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
|
||||
<p>${escapeHtml(check.message || "Подключение ещё не проверялось")}</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>` : ''}
|
||||
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button>
|
||||
</div>`;
|
||||
|
|
@ -1516,6 +1517,10 @@ function renderHealthView() {
|
|||
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);
|
||||
const warningsContainer = document.getElementById('health-warnings-list');
|
||||
if (warningsContainer) {
|
||||
|
|
@ -2452,9 +2457,10 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
|
|||
|
||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||
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) {
|
||||
showToast('Запрос обновления моделей отправлен', 'success');
|
||||
showToast(res.message, 'success');
|
||||
await fetchSnapshot();
|
||||
if (profileId) {
|
||||
setTimeout(() => openAccountDetailsModal(profileId, true), 500);
|
||||
} else {
|
||||
|
|
@ -2680,12 +2686,14 @@ function openAddAccountWizard() {
|
|||
window._wiz_redirect_slot_id = undefined;
|
||||
window._wiz_base_url = undefined;
|
||||
window._wiz_token = undefined;
|
||||
window._wiz_models = undefined;
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||
showWizardStep1();
|
||||
showModal();
|
||||
}
|
||||
|
||||
function showWizardStep1() {
|
||||
window._wiz_models = undefined;
|
||||
stopDeviceAuthPolling();
|
||||
stopRedirectAuthPolling();
|
||||
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_base_url = undefined;
|
||||
window._wiz_token = undefined;
|
||||
window._wiz_models = undefined;
|
||||
}
|
||||
window._wiz_provider = providerId;
|
||||
let bodyHtml = '';
|
||||
|
|
@ -2932,7 +2941,12 @@ function showWizardStep2(providerId) {
|
|||
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');
|
||||
if (baseInput) {
|
||||
window._wiz_base_url = baseInput.value.trim();
|
||||
|
|
@ -2954,7 +2968,21 @@ function proceedToWizardStep3(providerId) {
|
|||
window._wiz_device_profile = redirectSlot?.value || '';
|
||||
}
|
||||
// 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);
|
||||
} finally {
|
||||
window._wiz_validating = false;
|
||||
if (nextButton) nextButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showWizardStep3(providerId) {
|
||||
|
|
@ -2988,6 +3016,7 @@ function showWizardStep3(providerId) {
|
|||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 3 из 3: Назначение роли для нового аккаунта
|
||||
</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;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
|
||||
<select class="select-filter" style="width:100%;" id="wiz-target-role">
|
||||
|
|
@ -3033,6 +3062,7 @@ async function finishAddAccount(providerId) {
|
|||
const payload = {
|
||||
provider: providerId,
|
||||
target_role: targetRole,
|
||||
preferred_model: document.getElementById('wiz-preferred-model')?.value || '',
|
||||
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
|
||||
profile_id: selectedProfileId,
|
||||
};
|
||||
|
|
@ -3048,7 +3078,7 @@ async function finishAddAccount(providerId) {
|
|||
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
|
||||
|
||||
if (res && res.ok) {
|
||||
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
|
||||
showToast(res.message, 'success');
|
||||
closeModal();
|
||||
fetchSnapshot();
|
||||
} 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":
|
||||
try:
|
||||
cmd = [str(dest_file), "/silent", "/reinstall"]
|
||||
cmd = [str(dest_file), "/silent", "/reinstall", "/restart"]
|
||||
creation_flags = 0
|
||||
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(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()
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -111,7 +118,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
|
|||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
assert "сохранён" in res["message"]
|
||||
assert "проверено" in res["message"]
|
||||
|
||||
# Verify profile created and auth saved
|
||||
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 "сохранён" in res["message"]
|
||||
assert "проверено" in res["message"]
|
||||
|
||||
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
||||
assert auth is not None
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ def setup_isolated_env(tmp_path, monkeypatch):
|
|||
UnifiedHealthService._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 {
|
||||
"hermes_home": hermes_home,
|
||||
"profiles_yaml": profiles_yaml,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def isolated(tmp_path, monkeypatch):
|
|||
monkeypatch.setattr(AccountProbeService, "_instance", service)
|
||||
discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json")
|
||||
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)
|
||||
ActionExecutor._pending_connections.clear()
|
||||
yield service, discovery
|
||||
|
|
@ -65,16 +65,10 @@ def test_invalid_key_real_http_correct_slot(isolated, provider):
|
|||
"provider": provider, "token": "intentionally-invalid",
|
||||
"base_url": f"http://127.0.0.1:{server.server_port}/v1",
|
||||
})
|
||||
assert result["ok"]
|
||||
pid = result["data"]["profile_id"]
|
||||
assert pid.startswith(provider + "-")
|
||||
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"]
|
||||
assert not result["ok"]
|
||||
assert "401" in result["message"]
|
||||
assert not load_router_config().profiles
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
|
@ -187,13 +181,9 @@ def test_slow_action_does_not_block_web_health(isolated, monkeypatch):
|
|||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_repeated_connection_rejected_while_checking(isolated, monkeypatch):
|
||||
service, _ = isolated
|
||||
service.enabled = True
|
||||
monkeypatch.setattr(service._pool, "submit", lambda *args: None)
|
||||
payload = {"provider": "nvidia", "token": "intentionally-invalid-repeated"}
|
||||
first = ActionExecutor.execute("add_account", payload)
|
||||
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"]
|
||||
def test_repeated_invalid_connection_does_not_create_profiles(isolated):
|
||||
# Repeated invalid input cannot consume slots or create placeholder credentials.
|
||||
payload = {"provider": "nvidia", "token": "invalid", "base_url": "invalid-url"}
|
||||
assert not ActionExecutor.execute("add_account", payload)["ok"]
|
||||
assert not ActionExecutor.execute("add_account", payload)["ok"]
|
||||
assert not load_router_config().profiles
|
||||
|
|
|
|||
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
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from antigravity_provider.agy_subprocess import (
|
||||
check_profile_native_auth_status,
|
||||
launch_native_agy_login,
|
||||
)
|
||||
from antigravity_provider.paths import get_profile_dir
|
||||
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
|
||||
def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch):
|
||||
"""P0-2: launch_native_agy_login launches agy in profile's isolated directory."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
|
||||
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()
|
||||
def test_unused_console_login_removed():
|
||||
from antigravity_provider import agy_subprocess
|
||||
assert not hasattr(agy_subprocess, "launch_native_agy_login")
|
||||
|
||||
|
||||
# ── TEST 3: Detection of native agy authentication ──
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ async function runTests() {
|
|||
const slotForStep3 = getOrCreateElement('wiz-device-slot');
|
||||
slotForStep3.value = 'owner-slot-42';
|
||||
// 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
|
||||
const targetRoleForStep3 = getOrCreateElement('wiz-target-role');
|
||||
targetRoleForStep3.value = 'coder-primary';
|
||||
|
|
@ -274,7 +274,7 @@ async function runTests() {
|
|||
sandbox.showWizardStep2('local');
|
||||
const baseInput = getOrCreateElement('wiz-base-url-input');
|
||||
baseInput.value = 'http://127.0.0.1:8081/v1';
|
||||
sandbox.proceedToWizardStep3('local');
|
||||
await sandbox.proceedToWizardStep3('local');
|
||||
const targetRole = getOrCreateElement('wiz-target-role');
|
||||
targetRole.value = 'coder-primary';
|
||||
await sandbox.finishAddAccount('local');
|
||||
|
|
@ -310,13 +310,13 @@ async function runTests() {
|
|||
sandbox.openAddAccountWizard();
|
||||
sandbox.showWizardStep2('antigravity');
|
||||
getOrCreateElement('wiz-redirect-slot').value = 'ag-w1';
|
||||
sandbox.proceedToWizardStep3('antigravity');
|
||||
await sandbox.proceedToWizardStep3('antigravity');
|
||||
assert.strictEqual(sandbox.window._wiz_device_profile, 'ag-w1');
|
||||
sandbox.showWizardStep1();
|
||||
sandbox.showWizardStep2(provider);
|
||||
getOrCreateElement('wiz-redirect-slot').value = '';
|
||||
getOrCreateElement('wiz-token-input').value = 'intentionally-invalid';
|
||||
sandbox.proceedToWizardStep3(provider);
|
||||
await sandbox.proceedToWizardStep3(provider);
|
||||
await sandbox.finishAddAccount(provider);
|
||||
const action = [...executedActions].reverse().find(a => a.action === 'add_account');
|
||||
assert.strictEqual(action.data.provider, provider);
|
||||
|
|
|
|||
Loading…
Reference in a new issue