merge main
This commit is contained in:
commit
d5c8c316b4
27 changed files with 1312 additions and 42 deletions
169
agents/inbox/2026-09-01-A57-agy-native-login.md
Normal file
169
agents/inbox/2026-09-01-A57-agy-native-login.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Задание A57: вход в Antigravity через сам agy, а не через сочинённый файл
|
||||
|
||||
## Дата поступления
|
||||
2026-09-01
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`f188a18`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a57-agy-native-login origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
|
||||
|
||||
Зона: подключение аккаунтов Antigravity. С A56 (сжатие контекста) не пересекается.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Сейчас хаб проводит вход сам: получает токены по OAuth и **записывает чужой файл учётных данных своим кодом**. Формат этого файла ревьюер восстановил по рабочему профилю владельца и по строкам в бинарнике `agy`. Сегодня это работает. Но `agy` обновляется, и формат может измениться молча: файл останется на месте, вход перестанет засчитываться, а владелец увидит ровно ту необъяснимую картину, которую ловили полдня — «Авторизация успешно завершена» и тут же «Please sign in to view available models».
|
||||
|
||||
Правильный ответ — дать войти самому `agy`, с `HOME`, указывающим на каталог профиля. Тогда он пишет свои файлы своим форматом, сам обновляет токен по истечении часа, и гадать не о чем.
|
||||
|
||||
---
|
||||
|
||||
## Что проверено ревьюером — заново не выяснять
|
||||
|
||||
### Неинтерактивного входа у agy нет
|
||||
|
||||
`agy --help` (проверено запуском) даёт подкоманды: `agent`, `agents`, `changelog`, `help`, `install`, `mcp`, `mic-serve`, `models`, `plugin`, `plugins`, `update`. Подкоманд `login` или `auth` **нет**. Вход один: запустить CLI без аргументов и пройти его в терминале. Это же говорит текст ошибки самого agy:
|
||||
|
||||
```
|
||||
Error: Please sign in to view available models.
|
||||
Launch the CLI without arguments to sign in.
|
||||
```
|
||||
|
||||
Искать скрытый флаг входа не надо — его нет.
|
||||
|
||||
### Что agy держит в каталоге профиля
|
||||
|
||||
Рабочий профиль владельца `ag-orch-fallback` (11 моделей):
|
||||
|
||||
```
|
||||
.gemini/oauth_creds.json 1949 байт формат Gemini CLI
|
||||
.gemini/antigravity-cli/antigravity-oauth-token 505 байт {"auth_method":"consumer","token":{...}}
|
||||
.gemini/antigravity-cli/settings.json 107 байт enableTelemetry, trustedWorkspaces
|
||||
.gemini/antigravity-cli/installation_id 36 байт
|
||||
.gemini/antigravity-cli/jetski_state.pbtxt
|
||||
.gemini/antigravity-cli/history.jsonl
|
||||
.gemini/antigravity-cli/conversation_summaries.db
|
||||
auth.json файл хаба, не agy
|
||||
```
|
||||
|
||||
Вход agy читает из `antigravity-oauth-token`, а не из `oauth_creds.json`. Это установлено сравнением рабочего профиля с неработающим и подтверждено исполнением: файл создали руками для `ag-4` — `agy models` тут же выдал 11 моделей.
|
||||
|
||||
### Изоляция по HOME уже работает
|
||||
|
||||
`get_profile_env_dir`, `build_safe_subprocess_env` и `hidden_process_kwargs` существуют и применяются: `agy models` вызывается с `HOME`/`USERPROFILE`, указывающими на каталог профиля. Заново это писать не надо.
|
||||
|
||||
### Заготовка уже есть
|
||||
|
||||
`launch_native_agy_login` в `agy_subprocess.py` запускает agy с `CREATE_NEW_CONSOLE`. Её никто не вызывает — числится мёртвым кодом с A54. Это отправная точка, а не мусор.
|
||||
|
||||
### Рост номеров профилей починен
|
||||
|
||||
Слот выбирался до входа, когда почта ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный номер: один аккаунт владельца расползся на ag-2, ag-3, ag-4. В `9641957` после опознания почты учётные данные возвращаются в слот, который этот аккаунт уже занимает. **Не переделывать.**
|
||||
|
||||
### Среда владельца
|
||||
|
||||
Сервер: Ubuntu 24.04, рабочий стол на месте (Chrome запускается), владелец сидит за машиной. Вторая машина — Windows 11. Хаб работает на обеих.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Вход выполняет сам agy
|
||||
|
||||
1. **Запуск `agy` в настоящем терминале** с `HOME` и `USERPROFILE`, указывающими на каталог выбранного профиля. Владелец проходит вход глазами и руками — это единственный доступный способ.
|
||||
2. **Терминал не подразумевать, а искать.** На Linux проверить наличие эмулятора (`x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm`) и назвать в отказе, что именно искали. Отсутствие терминала — честный отказ с перечнем проверенного, а не молчание.
|
||||
3. **Файл учётных данных хаб больше не сочиняет.** `write_agy_oauth_creds` и запись `antigravity-oauth-token` остаются только для запасного браузерного пути (P0-4).
|
||||
4. **Ждать окончания входа по появлению файла**, а не по коду возврата терминала: эмулятор часто отсоединяется сразу. Ждать `antigravity-oauth-token` в каталоге профиля с разумным пределом и внятным сообщением по его истечении.
|
||||
|
||||
## P0-2. Слот выбирается до входа и не меняется
|
||||
|
||||
1. **Каталог профиля определяется заранее** и передаётся через `HOME`. Вход физически не может уйти в чужой каталог — в этом весь смысл.
|
||||
2. **Занятый слот не перезаписывать.** Если в каталоге уже лежит рабочий вход другого аккаунта, предупредить и потребовать подтверждения.
|
||||
3. **Каталоги существующих аккаунтов не трогать.** Их два десятка, повторный вход руками стоит владельцу часов.
|
||||
|
||||
## P0-3. Почта берётся из профиля, а не выдумывается
|
||||
|
||||
1. **После входа опознать аккаунт**, прочитав то, что записал agy. Если почту установить не удалось — показать `Н/Д` с причиной, а не подставить правдоподобное.
|
||||
2. **Проверить двойников** уже существующим `AutoAssigner.check_duplicate_identity` и вернуть учётные данные в занятый этим аккаунтом слот, если он есть.
|
||||
3. **Число и время получения моделей** показывать рядом со списком.
|
||||
|
||||
## P0-4. Браузерный путь остаётся запасным
|
||||
|
||||
1. **Не удалять существующий OAuth.** С другой машины через браузер это единственный способ, и он работает.
|
||||
2. **Владелец выбирает способ** в мастере: вход в терминале на этой машине или по ссылке из браузера. Предлагать первым тот, который на текущей машине выполним.
|
||||
3. **Текст объясняет разницу**: терминал доступен только там, где стоит хаб.
|
||||
|
||||
## P0-5. Отказ доходит до владельца
|
||||
|
||||
1. **Причина отказа — текстом.** `agy` пишет её в stderr; она обязана попадать в интерфейс вместе с указанием `HOME`, с которым шёл запуск.
|
||||
2. **Пустых сообщений быть не должно.** Запасной текст «Отказ выполнения действия» означает потерянную причину.
|
||||
3. **Проверка после входа не блокирует ответ.** Действие возвращается сразу, опрос провайдера идёт в фоне — это сделано в `0ad946e`, не откатывать.
|
||||
|
||||
## P0-6. Безопасность
|
||||
|
||||
1. **Токены и коды в интерфейс не выводить и в журнал не писать.** В сообщениях допустимы пути и имена файлов, но не содержимое.
|
||||
2. **Права на каталог и файлы** — `0700` и `0600`.
|
||||
3. **`~/.hermes/agy_profiles/` не чистить** и не трогать чужие профили.
|
||||
|
||||
## P0-7. Проверка исполнением
|
||||
|
||||
Тестами это не ловится: все прежние дефекты входа прошли через зелёный прогон.
|
||||
|
||||
1. **Подключить аккаунт через терминал на сервере** и приложить вывод `agy models` для этого профиля.
|
||||
2. **Подключить второй аккаунт** и убедиться, что первый не задет: у обоих свои каталоги и свои почты.
|
||||
3. **Повторить вход тем же аккаунтом** и убедиться, что новый слот не создаётся.
|
||||
4. **Проверить отказ** при отсутствии терминала: сообщение перечисляет, что искали.
|
||||
5. **Проверить, что браузерный путь по-прежнему работает** с другой машины.
|
||||
|
||||
## P0-8. Аудит вторым проходом
|
||||
|
||||
1. **Пройти вход целиком на обеих машинах**, скриншоты приложить.
|
||||
2. **Убедиться, что хаб не пишет `antigravity-oauth-token`** на пути через CLI: файл создаёт agy.
|
||||
3. **Проверить, что при отказе входа профиль не остаётся наполовину заполненным** и не числится подключённым.
|
||||
4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза.
|
||||
5. **Побочные изменения** объяснить.
|
||||
6. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Учётные данные и каталоги существующих аккаунтов не трогать.
|
||||
- Правки ревьюера из `main` не откатывать: `9641957` (запись токена и слоты), `0ad946e` (проверка в фоне), `f188a18` (устаревший вердикт).
|
||||
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
|
||||
- Адреса, пути и имена терминалов в код не зашивать вслепую: искать и сообщать, что искали.
|
||||
- Версию `0.1.3` не понижать.
|
||||
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной; отсутствие прав — не то же самое, что отсутствие файла.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Вход через `agy` в терминале работает на Linux и на Windows; вывод `agy models` приложен.
|
||||
3. Файл `antigravity-oauth-token` на этом пути создаёт agy, а не хаб.
|
||||
4. Слот задаётся до входа через `HOME`; чужой каталог затронуть невозможно.
|
||||
5. Повторный вход тем же аккаунтом не создаёт новый слот.
|
||||
6. Почта берётся из профиля; неустановленная показывается как `Н/Д` с причиной.
|
||||
7. Браузерный путь сохранён и проверен с другой машины.
|
||||
8. Отсутствие терминала даёт отказ с перечнем проверенного.
|
||||
9. Токены не попадают ни в интерфейс, ни в журнал.
|
||||
10. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **626**.
|
||||
11. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||
|
||||
## Главное
|
||||
|
||||
Хаб сегодня подделывает чужой формат учётных данных. Это работает ровно до следующего обновления `agy`, и отказ будет молчаливым: файл на месте, вход не засчитан, причина неочевидна. Владелец уже потерял на этом день.
|
||||
|
||||
`agy` умеет входить сам и делает это правильно по определению. Задание переносит вход туда, где ему место, оставляя браузерный путь для удалённого случая.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"hub_version": "0.1.2",
|
||||
"hub_version": "0.1.3",
|
||||
"min_hermes_version": "0.20.0",
|
||||
"max_tested_hermes_version": "0.20.4",
|
||||
"tested_versions": [
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ namespace HermesHubSetup
|
|||
{
|
||||
public class SetupEngine
|
||||
{
|
||||
public const string HUB_VERSION = "0.1.2";
|
||||
public const string HUB_VERSION = "0.1.3";
|
||||
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
||||
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
||||
// какого кода собран установщик.
|
||||
public const string BuildCommit = "8daeafa";
|
||||
public const string BuildCommit = "0ad946e";
|
||||
public const string MIN_HERMES_VERSION = "0.20.0";
|
||||
public const string MAX_TESTED_HERMES = "0.20.4";
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,31 @@
|
|||
|
||||
set -e
|
||||
|
||||
HUB_VERSION="0.1.2"
|
||||
HUB_VERSION="0.1.3"
|
||||
DEFAULT_HERMES_HOME="$HOME/.hermes"
|
||||
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
|
||||
|
||||
# Установка пользовательская: всё ложится в $HOME/.hermes и $HOME/.local/bin,
|
||||
# службы не ставятся, root не нужен. Под sudo домашним каталогом становится
|
||||
# /root, венв Hermes там не находится, установщик сваливается на системный
|
||||
# python — а он в Ubuntu 24.04 закрыт для pip (PEP 668). В итоге установка
|
||||
# уходит в /root/.hermes, где владелец её не видит, и падает на проверке.
|
||||
# Молча ставить не туда нельзя, поэтому отказываемся сразу и по делу.
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && [ -z "${HERMES_ALLOW_ROOT:-}" ]; then
|
||||
echo "❌ Установщик запущен через sudo." >&2
|
||||
echo "" >&2
|
||||
echo " Hermes Hub ставится в домашний каталог пользователя, а под sudo" >&2
|
||||
echo " это /root — туда, где ни аккаунты, ни Hermes Agent не лежат." >&2
|
||||
echo "" >&2
|
||||
# $0 здесь — распакованная копия во временном каталоге, называть её
|
||||
# владельцу бессмысленно: этого файла через минуту не будет.
|
||||
echo " Запустите тот же установщик от своего имени, без sudo." >&2
|
||||
echo "" >&2
|
||||
echo " Если установка в /root действительно нужна, задайте" >&2
|
||||
echo " HERMES_ALLOW_ROOT=1." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
|
|
@ -22,6 +43,60 @@ echo "Hermes Home : $HERMES_HOME"
|
|||
echo "Source Root : $REPO_ROOT"
|
||||
echo ""
|
||||
|
||||
# 0. Остановка работающего хаба.
|
||||
#
|
||||
# Установщик копировал файлы, но работающий сервер не трогал: он продолжал
|
||||
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом
|
||||
# номере сборки. Три сборки подряд ставились в файлы, но не в работу.
|
||||
echo "[0/6] Остановка работающего Hermes Hub..."
|
||||
|
||||
stop_running_hub() {
|
||||
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
|
||||
local pids
|
||||
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo " Работающий хаб не найден — останавливать нечего."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Найдены процессы хаба: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill $pids 2>/dev/null || true
|
||||
|
||||
local waited=0
|
||||
while [ "$waited" -lt 10 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
[ -z "$pids" ] && break
|
||||
done
|
||||
|
||||
if [ -n "$pids" ]; then
|
||||
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
fi
|
||||
|
||||
if [ -n "$pids" ]; then
|
||||
# Не прерываем установку: файлы обновятся, а владельцу скажем правду.
|
||||
echo " ⚠ Остались процессы: $pids. Снимите их вручную, иначе будет работать старый код."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Хаб остановлен."
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_running_hub || true
|
||||
echo ""
|
||||
|
||||
# 1. Check / Discover Python Runtime and Hermes Environment
|
||||
echo "[1/6] Checking Python and Hermes environment..."
|
||||
PYTHON_BIN=""
|
||||
|
|
@ -30,6 +105,8 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
|
|||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
|
||||
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
|
||||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
|
||||
elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then
|
||||
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="$(command -v python3)"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
|
|
@ -57,12 +134,48 @@ echo "[2/6] Verifying Python dependencies..."
|
|||
DEPS_OK=true
|
||||
"$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml; print('DEPS_OK')" >/dev/null 2>&1 || DEPS_OK=false
|
||||
|
||||
HUB_DEPS="fastapi uvicorn pydantic psutil pyyaml"
|
||||
|
||||
if [ "$DEPS_OK" != "true" ]; then
|
||||
echo " Installing required packages (fastapi, uvicorn, pydantic, psutil, pyyaml)..."
|
||||
"$PYTHON_BIN" -m pip install --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || {
|
||||
echo "⚠️ Warning: pip install returned non-zero code. Trying with --user..."
|
||||
"$PYTHON_BIN" -m pip install --user --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || true
|
||||
}
|
||||
# Системный python в Debian и Ubuntu помечен как externally-managed
|
||||
# (PEP 668) и отклоняет pip install — и обычный, и с --user. Обходить это
|
||||
# через --break-system-packages нельзя: имя флага не преувеличивает, так
|
||||
# ломают питон всей машины. Правильный ответ — собственный venv.
|
||||
NEED_VENV=false
|
||||
if [ -f "/usr/lib/python$PY_VER/EXTERNALLY-MANAGED" ] || [ -f "/usr/lib/python3/EXTERNALLY-MANAGED" ]; then
|
||||
case "$PYTHON_BIN" in
|
||||
*/venv/bin/*) : ;;
|
||||
*) NEED_VENV=true ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$NEED_VENV" = "true" ]; then
|
||||
echo " Системный Python защищён от изменений (PEP 668)."
|
||||
echo " Создаю отдельное окружение: $HERMES_HOME/venv"
|
||||
if ! "$PYTHON_BIN" -m venv "$HERMES_HOME/venv" 2>/dev/null; then
|
||||
echo "❌ Не удалось создать виртуальное окружение." >&2
|
||||
echo " Установите пакет python3-venv:" >&2
|
||||
echo " sudo apt install python3-venv" >&2
|
||||
exit 11
|
||||
fi
|
||||
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
|
||||
echo " Using Python: $PYTHON_BIN"
|
||||
fi
|
||||
|
||||
echo " Installing required packages ($HUB_DEPS)..."
|
||||
# shellcheck disable=SC2086
|
||||
if ! "$PYTHON_BIN" -m pip install --no-warn-script-location -q $HUB_DEPS; then
|
||||
echo "❌ Не удалось установить зависимости через $PYTHON_BIN." >&2
|
||||
echo " Установка прервана: без них хаб не запустится." >&2
|
||||
exit 12
|
||||
fi
|
||||
fi
|
||||
|
||||
# Проверяем результат, а не код возврата pip: установка «прошла», а модуля нет —
|
||||
# именно так предыдущая сборка дошла до проверки и упала на ней.
|
||||
if ! "$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml" >/dev/null 2>&1; then
|
||||
echo "❌ Зависимости не импортируются даже после установки ($PYTHON_BIN)." >&2
|
||||
exit 13
|
||||
fi
|
||||
|
||||
# 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files)
|
||||
|
|
@ -203,5 +316,12 @@ echo "To launch the Web Application window:"
|
|||
echo " $LAUNCHER_BIN"
|
||||
echo ""
|
||||
echo "Or open 'Hermes Hub Web' from your Applications menu."
|
||||
echo ""
|
||||
echo "ВАЖНО: работавший хаб был остановлен перед установкой, иначе он"
|
||||
echo "продолжал бы выполнять прежний код из памяти. Запустите его заново"
|
||||
echo "командой выше — только тогда новая сборка начнёт работать."
|
||||
echo ""
|
||||
echo "Проверить, что поднялся новый код:"
|
||||
echo " curl -s -D - -o /dev/null http://127.0.0.1:5800/workspace.js | grep -i cache-control"
|
||||
echo "======================================================================"
|
||||
exit 0
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -16,6 +16,11 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
|
|||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
|
||||
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
|
||||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
|
||||
elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then
|
||||
# Окружение, созданное установщиком, когда системный python закрыт
|
||||
# правилом PEP 668. Без этой ветки запуск уходил бы на /usr/bin/python3,
|
||||
# где зависимостей нет и быть не может.
|
||||
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="$(command -v python3)"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "hermes-hub"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
|
|
|||
|
|
@ -46,12 +46,32 @@ def _find_agy_exe() -> str:
|
|||
if env and Path(env).is_file():
|
||||
return env
|
||||
|
||||
# 2. Standard location based on hermes home parent
|
||||
# 2. Стандартные места установки.
|
||||
#
|
||||
# Раньше проверялась только раскладка Windows (%LOCALAPPDATA%/agy/bin), а в
|
||||
# Linux agy ставится в ~/.local/bin. Хаб запускается с урезанным окружением,
|
||||
# где этого каталога в PATH нет, и вход в Antigravity падал с «agy executable
|
||||
# not found» при установленной и работающей утилите.
|
||||
from antigravity_provider.paths import get_hermes_home
|
||||
|
||||
exe_name = "agy.exe" if os.name == "nt" else "agy"
|
||||
candidate = get_hermes_home().parent / "agy" / "bin" / exe_name
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
candidates = [
|
||||
get_hermes_home().parent / "agy" / "bin" / exe_name,
|
||||
Path.home() / ".local" / "bin" / exe_name,
|
||||
Path("/usr/local/bin") / exe_name,
|
||||
Path("/usr/bin") / exe_name,
|
||||
Path("/snap/bin") / exe_name,
|
||||
]
|
||||
checked = []
|
||||
for candidate in candidates:
|
||||
checked.append(str(candidate))
|
||||
try:
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
except OSError as exc:
|
||||
# Каталог может быть закрыт правами: это «не смогли проверить»,
|
||||
# а не «файла нет».
|
||||
checked[-1] = f"{candidate} (нет доступа: {exc.strerror or exc})"
|
||||
|
||||
# 3. PATH
|
||||
found = shutil.which("agy") or shutil.which("agy.exe")
|
||||
|
|
@ -59,8 +79,9 @@ def _find_agy_exe() -> str:
|
|||
return found
|
||||
|
||||
raise FileNotFoundError(
|
||||
"agy executable not found. Set the AGY_EXE_PATH environment "
|
||||
"variable, install agy, or ensure it is on PATH."
|
||||
"Утилита agy не найдена. Проверено: "
|
||||
+ "; ".join(checked)
|
||||
+ "; и PATH процесса. Задайте путь переменной AGY_EXE_PATH либо установите agy."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -207,7 +228,18 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
|||
raw = result.stdout.strip()
|
||||
if not raw or result.returncode != 0:
|
||||
if profile_id:
|
||||
raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен")
|
||||
# Прежнее сообщение «код 1; каталог не получен» скрывало причину.
|
||||
# agy пишет её в stderr, и без неё непонятно главное: он
|
||||
# запускается с HOME, подменённым на каталог профиля (ради
|
||||
# изоляции учётных данных между аккаунтами). Если вход
|
||||
# выполнялся обычным agy в оболочке, ключи легли в настоящий
|
||||
# домашний каталог, и профиль пуст — отсюда отказ.
|
||||
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
detail = detail.splitlines()[-1][:300] if detail else "вывод пуст"
|
||||
raise RuntimeError(
|
||||
f"agy models: код {result.returncode}. Ответ agy: {detail}. "
|
||||
f"Запуск с HOME={env.get('HOME') or env.get('USERPROFILE') or 'не задан'}"
|
||||
)
|
||||
return dict(_AGY_MODEL_CACHE or {})
|
||||
except Exception:
|
||||
if profile_id:
|
||||
|
|
@ -724,6 +756,31 @@ def agy_generate(
|
|||
"--disable-slash-commands",
|
||||
"--print-timeout", f"{timeout}s",
|
||||
]
|
||||
# Каталог agy отдаёт идентификаторы с уровнем усилия: gemini-3.7-flash-high,
|
||||
# -medium, -low. В профиле же хранится голое имя, и вызов уходил с пустым
|
||||
# --effort: agy отвечал «gemini-3.7-flash requires --effort (available: low,
|
||||
# medium, high)» и работа не начиналась.
|
||||
if agy_model and not agy_effort:
|
||||
base, _, tail = str(agy_model).rpartition("-")
|
||||
if base and tail in ("low", "medium", "high"):
|
||||
# Уровень зашит в самом имени — отделяем его.
|
||||
agy_model, agy_effort = base, tail
|
||||
else:
|
||||
known = _AGY_EFFORT_MAP.get(agy_model) or set()
|
||||
if known:
|
||||
# Берём средний уровень, если он есть: он и по названию средний,
|
||||
# и по расходу квоты. Иначе — любой доступный, по порядку.
|
||||
for candidate in ("medium", "high", "low"):
|
||||
if candidate in known:
|
||||
agy_effort = candidate
|
||||
break
|
||||
if not agy_effort:
|
||||
agy_effort = sorted(known)[0]
|
||||
logger.info(
|
||||
"agy_generate: у модели %s не задан уровень усилия, выбран %s из %s",
|
||||
agy_model, agy_effort, sorted(known),
|
||||
)
|
||||
|
||||
if agy_model:
|
||||
cmd.extend(["--model", agy_model])
|
||||
if agy_effort:
|
||||
|
|
|
|||
|
|
@ -708,6 +708,50 @@ class ActionExecutor:
|
|||
return {'ok': False, 'message': reason, 'data': {'status': status}}
|
||||
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
|
||||
|
||||
if action == 'probe_account_models':
|
||||
# Определение доступных аккаунту моделей. Запускается только по
|
||||
# явному действию владельца: каталог NVIDIA публичный и о правах
|
||||
# аккаунта ничего не сообщает, поэтому доступность выясняется
|
||||
# опросом, а он тратит вызовы и упирается в ограничения частоты.
|
||||
from .model_entitlements import probe_account_models, load_entitlements
|
||||
from .model_discovery_service import ModelDiscoveryService
|
||||
|
||||
pid = data.get('profile_id') or ''
|
||||
prov_norm = (prov or data.get('provider') or '').strip().lower()
|
||||
if not pid or not prov_norm:
|
||||
return {'ok': False, 'message': 'Не указан профиль или провайдер'}
|
||||
|
||||
if data.get('cached_only'):
|
||||
cached = load_entitlements(prov_norm, pid)
|
||||
if not cached:
|
||||
return {'ok': True, 'message': 'Н/Д: доступность моделей ещё не определялась', 'data': {}}
|
||||
return {'ok': True, 'message': 'Сохранённый результат', 'data': cached}
|
||||
|
||||
auth = ProfileAuthManager.load_profile_auth(prov_norm, pid) or {}
|
||||
token = (auth.get('api_key') or auth.get('token') or '').strip()
|
||||
if not token:
|
||||
return {'ok': False, 'message': f'У профиля {pid} нет сохранённого API-ключа'}
|
||||
|
||||
pcfg = load_router_config().get_profile(pid)
|
||||
base_url = (getattr(pcfg, 'custom_base_url', None) or auth.get('base_url') or '').strip()
|
||||
if not base_url:
|
||||
base_url = 'https://integrate.api.nvidia.com/v1' if prov_norm.startswith('nvidia') else ''
|
||||
if not base_url:
|
||||
return {'ok': False, 'message': f'Не известен адрес провайдера для {pid}'}
|
||||
|
||||
models = ModelDiscoveryService.get().get_models(prov_norm) or []
|
||||
if not models:
|
||||
return {'ok': False, 'message': 'Каталог моделей ещё не получен — сначала запросите список'}
|
||||
|
||||
res = probe_account_models(prov_norm, pid, token, base_url, models)
|
||||
d = res.to_dict()
|
||||
msg = (
|
||||
f"Доступно {len(res.available)} из {d['total']}; "
|
||||
f"не выдано {len(res.unavailable)}; "
|
||||
f"не определено {len(res.undetermined)}"
|
||||
)
|
||||
return {'ok': True, 'message': msg, 'data': d}
|
||||
|
||||
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 '')
|
||||
|
|
@ -857,7 +901,27 @@ class ActionExecutor:
|
|||
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)
|
||||
# Проверку у провайдера не ждём в самом действии. Для
|
||||
# Antigravity она идёт через CLI и в худшем случае занимает до
|
||||
# 90 с на захват замка, до 65 на каталог моделей и до 90 на
|
||||
# пробный вызов — около четырёх минут молчания при обещанной
|
||||
# «минуте на этап». Владелец видит это как зависший мастер.
|
||||
#
|
||||
# Провайдеры с ключом сюда не попадают: их подключение уже
|
||||
# проверено предварительной проверкой выше и возвращается
|
||||
# сразу, как того требует A54.
|
||||
probe = AccountProbeService.get()
|
||||
started = probe.schedule(prov_norm, slot, force=True)
|
||||
if started or probe.state(slot).get('state') == 'checking':
|
||||
return {
|
||||
'ok': True,
|
||||
'message': 'Аккаунт подключён. Проверка у провайдера идёт в фоне, '
|
||||
'результат появится в карточке.',
|
||||
'data': {'profile_id': slot, 'check': 'running'},
|
||||
}
|
||||
# Фоновая служба не работает — проверяем здесь, иначе результата
|
||||
# не будет вовсе. Ручная проверка обязана работать и без неё.
|
||||
result = probe.check_now(prov_norm, slot)
|
||||
result.setdefault('data', {})['profile_id'] = slot
|
||||
return result
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -38,6 +38,20 @@ class NvidiaAdapter(BaseProviderAdapter):
|
|||
if key:
|
||||
return str(key).strip()
|
||||
|
||||
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
|
||||
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
|
||||
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
|
||||
# authorization was missing», хотя список моделей тем же ключом получался.
|
||||
try:
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||
key = stored.get("api_key") or stored.get("token")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
suffix = profile.profile_id.upper().replace("-", "_")
|
||||
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
|
||||
val = os.environ.get(candidate, "").strip()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .local_adapter import LocalLLMAdapter
|
|||
logger = logging.getLogger("hermes.router.adapter.ollama")
|
||||
|
||||
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
||||
CLOUD_OLLAMA_BASE_URL = "https://ollama.com"
|
||||
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
|
||||
|
||||
|
||||
|
|
@ -25,11 +26,34 @@ class OllamaAdapter(LocalLLMAdapter):
|
|||
and native Ollama endpoints (/api/tags).
|
||||
"""
|
||||
|
||||
def _stored_base_url(self, profile: RouterProfileConfig) -> str:
|
||||
"""Адрес из хранилища учётных данных, если владелец его задавал."""
|
||||
try:
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||
return str(stored.get("base_url") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
|
||||
"""Resolve base_url from profile custom_base_url, auth_config, or environment."""
|
||||
url = (
|
||||
"""Адрес сервера: локальный либо облачный.
|
||||
|
||||
У аккаунта Ollama Cloud локального сервера нет вовсе — только ключ.
|
||||
Раньше адаптер всегда шёл на 127.0.0.1:11434, получал «Connection
|
||||
refused» и объявлял облачный аккаунт неработающим, хотя каталог из
|
||||
девятнадцати моделей у него получался.
|
||||
"""
|
||||
explicit = (
|
||||
profile.custom_base_url
|
||||
or profile.auth_config.get("base_url")
|
||||
or self._stored_base_url(profile)
|
||||
)
|
||||
if not explicit and self._resolve_api_key(profile):
|
||||
return CLOUD_OLLAMA_BASE_URL
|
||||
|
||||
url = (
|
||||
explicit
|
||||
or os.environ.get("OLLAMA_BASE_URL")
|
||||
or os.environ.get("OLLAMA_HOST")
|
||||
or DEFAULT_OLLAMA_BASE_URL
|
||||
|
|
@ -45,6 +69,18 @@ class OllamaAdapter(LocalLLMAdapter):
|
|||
if key:
|
||||
return str(key).strip()
|
||||
|
||||
# Ключ, введённый в мастере, лежит в хранилище учётных данных, а не в
|
||||
# auth_config из router_profiles.yaml.
|
||||
try:
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||
key = stored.get("api_key") or stored.get("token")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
suffix = profile.profile_id.upper().replace("-", "_")
|
||||
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
|
||||
val = os.environ.get(candidate, "").strip()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,20 @@ class OpenRouterAdapter(BaseProviderAdapter):
|
|||
if key:
|
||||
return str(key).strip()
|
||||
|
||||
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
|
||||
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
|
||||
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
|
||||
# authorization was missing», хотя список моделей тем же ключом получался.
|
||||
try:
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||
key = stored.get("api_key") or stored.get("token")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
suffix = profile.profile_id.upper().replace("-", "_")
|
||||
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
|
||||
val = os.environ.get(candidate, "").strip()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
|||
raise ValueError("Для этого провайдера используйте вход через авторизацию")
|
||||
if provider not in ("local", "vllm", "ollama") and not token:
|
||||
raise ValueError("Не указан API-ключ")
|
||||
|
||||
# Подписочные аккаунты Codex (ChatGPT) и Claude работают по OAuth, а не
|
||||
# по платформенному ключу. Проверка ниже обращается к каталогу моделей,
|
||||
# где платформенному ключу нужны права api.model.read: подписочный
|
||||
# токен там получает 403 или 401, хотя аккаунт исправен и в Hermes
|
||||
# работает. Предупреждаем об этом заранее, чтобы отказ не выглядел
|
||||
# поломкой аккаунта.
|
||||
subscription_note = ""
|
||||
if provider in ("openai-codex", "codex", "claude", "anthropic"):
|
||||
subscription_note = (
|
||||
" Если это аккаунт по подписке (ChatGPT Plus/Pro или Claude Max), "
|
||||
"ключ здесь не подойдёт: такие аккаунты подключаются входом по ссылке "
|
||||
"на предыдущем шаге мастера, а не API-ключом."
|
||||
)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if provider == "claude":
|
||||
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
|
||||
|
|
@ -97,6 +111,41 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
|||
except Exception as exc:
|
||||
exc_str = str(exc)
|
||||
exc_lower = exc_str.lower()
|
||||
# У Ollama Cloud локального сервера нет вовсе: там только ключ. Если
|
||||
# ключ задан, а локальный адрес не отвечает, это не отказ подключения —
|
||||
# аккаунт облачный. Проверяем его по общедоступному каталогу и
|
||||
# принимаем, честно сказав, что локальные модели недоступны.
|
||||
if provider == "ollama" and token and any(
|
||||
k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "refused", "failed to connect", "target machine actively refused")
|
||||
):
|
||||
try:
|
||||
cloud = urllib.request.Request(
|
||||
"https://ollama.com/api/tags",
|
||||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||
)
|
||||
with urllib.request.urlopen(cloud, timeout=15) as response:
|
||||
payload = json.load(response)
|
||||
cloud_models = sorted({
|
||||
m.get("name") for m in (payload.get("models") or [])
|
||||
if isinstance(m, dict) and isinstance(m.get("name"), str)
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"Облачный аккаунт Ollama принят. Моделей в каталоге: {len(cloud_models)}. "
|
||||
f"Локальный сервер по адресу {base_url} не отвечает — локальные модели недоступны."
|
||||
),
|
||||
"data": {"models": cloud_models, "base_url": base_url, "cloud_only": True},
|
||||
}
|
||||
except Exception as cloud_exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": (
|
||||
f"Локальный сервер {base_url} не отвечает, и облачный каталог Ollama недоступен: "
|
||||
f"{str(cloud_exc)[:160]}"
|
||||
),
|
||||
"data": {"models": []},
|
||||
}
|
||||
if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")):
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -104,7 +153,9 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
|||
"data": {"models": []},
|
||||
}
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
reason = exc.reason or 'провайдер отклонил запрос'
|
||||
reason = (exc.reason or 'провайдер отклонил запрос')
|
||||
if exc.code in (401, 403) and subscription_note:
|
||||
reason = str(reason) + subscription_note
|
||||
try:
|
||||
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
|
||||
detail = body.get('error') or body.get('detail')
|
||||
|
|
|
|||
180
src/antigravity_provider/router/model_entitlements.py
Normal file
180
src/antigravity_provider/router/model_entitlements.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Определение моделей, доступных конкретному аккаунту.
|
||||
|
||||
Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и
|
||||
возвращает один и тот же список всем. Полей о доступности в нём нет — только
|
||||
`id`, `object`, `created`, `owned_by`. Проверено запросом.
|
||||
|
||||
Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя.
|
||||
Единственный достоверный способ — спросить у самой модели. Недоступная
|
||||
отвечает до генерации:
|
||||
|
||||
404 Function ... Not found for account '<id>'
|
||||
|
||||
то есть проверка недоступной модели не стоит ничего, а доступной — один токен
|
||||
при `max_tokens=1`.
|
||||
|
||||
Опрос запускается ТОЛЬКО по явному действию владельца и результат сохраняется:
|
||||
83 запроса подряд упираются в ограничения провайдера, и делать это молча при
|
||||
каждом подключении неправильно.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from antigravity_provider import paths
|
||||
|
||||
logger = logging.getLogger("hermes.router.entitlements")
|
||||
|
||||
# Опрашиваем небольшими пачками: провайдеры ограничивают частоту, а выигрыш от
|
||||
# большего числа потоков всё равно съедается их лимитом.
|
||||
DEFAULT_WORKERS = 8
|
||||
DEFAULT_TIMEOUT_SEC = 20.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntitlementResult:
|
||||
"""Итог опроса одного аккаунта."""
|
||||
|
||||
provider: str
|
||||
profile_id: str
|
||||
checked_at: float
|
||||
available: List[str] = field(default_factory=list)
|
||||
unavailable: List[str] = field(default_factory=list)
|
||||
undetermined: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"profile_id": self.profile_id,
|
||||
"checked_at": self.checked_at,
|
||||
"available": list(self.available),
|
||||
"unavailable": list(self.unavailable),
|
||||
"undetermined": dict(self.undetermined),
|
||||
"total": len(self.available) + len(self.unavailable) + len(self.undetermined),
|
||||
}
|
||||
|
||||
|
||||
def _store_path() -> Any:
|
||||
return paths.get_hermes_home() / "model_entitlements.json"
|
||||
|
||||
|
||||
def load_entitlements(provider: str, profile_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Ранее определённая доступность или None, если опрос не проводился."""
|
||||
try:
|
||||
path = _store_path()
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
return data.get(f"{provider}:{profile_id}")
|
||||
except Exception as exc:
|
||||
logger.debug("Не удалось прочитать сохранённую доступность: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_entitlements(result: EntitlementResult) -> None:
|
||||
path = _store_path()
|
||||
try:
|
||||
data: Dict[str, Any] = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
data = {}
|
||||
data[f"{result.provider}:{result.profile_id}"] = result.to_dict()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning("Не удалось сохранить доступность моделей: %s", exc)
|
||||
|
||||
|
||||
def _probe_one(base_url: str, token: str, model: str, timeout: float) -> tuple:
|
||||
"""Вернуть (model, state, note): state — available | unavailable | undetermined."""
|
||||
body = json.dumps(
|
||||
{"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
base_url.rstrip("/") + "/chat/completions",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
payload = json.load(response)
|
||||
if isinstance(payload.get("choices"), list):
|
||||
return (model, "available", "")
|
||||
return (model, "undetermined", "провайдер вернул ответ без choices")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# 404 — модель аккаунту не выдана. Это ответ провайдера, а не сбой.
|
||||
if exc.code == 404:
|
||||
return (model, "unavailable", "не выдана аккаунту")
|
||||
# 401/403 — отвергнут сам ключ: дальше опрашивать бессмысленно.
|
||||
if exc.code in (401, 403):
|
||||
return (model, "undetermined", f"ключ отвергнут (HTTP {exc.code})")
|
||||
if exc.code == 429:
|
||||
return (model, "undetermined", "превышена частота запросов")
|
||||
return (model, "undetermined", f"HTTP {exc.code}")
|
||||
except Exception as exc:
|
||||
return (model, "undetermined", str(exc)[:80])
|
||||
|
||||
|
||||
def probe_account_models(
|
||||
provider: str,
|
||||
profile_id: str,
|
||||
token: str,
|
||||
base_url: str,
|
||||
models: List[str],
|
||||
workers: int = DEFAULT_WORKERS,
|
||||
timeout: float = DEFAULT_TIMEOUT_SEC,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
) -> EntitlementResult:
|
||||
"""Опросить каталог и разложить модели по доступности.
|
||||
|
||||
Модель, про которую ответ неоднозначен, попадает в `undetermined` с
|
||||
причиной — выдавать её за доступную или недоступную нельзя.
|
||||
"""
|
||||
result = EntitlementResult(
|
||||
provider=provider, profile_id=profile_id, checked_at=time.time()
|
||||
)
|
||||
if not models:
|
||||
return result
|
||||
if not token:
|
||||
result.undetermined = {m: "ключ не задан" for m in models}
|
||||
return result
|
||||
|
||||
done = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def _run(model: str):
|
||||
nonlocal done
|
||||
outcome = _probe_one(base_url, token, model, timeout)
|
||||
with lock:
|
||||
done += 1
|
||||
if progress:
|
||||
try:
|
||||
progress(done, len(models))
|
||||
except Exception:
|
||||
pass
|
||||
return outcome
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
||||
for model, state, note in pool.map(_run, models):
|
||||
if state == "available":
|
||||
result.available.append(model)
|
||||
elif state == "unavailable":
|
||||
result.unavailable.append(model)
|
||||
else:
|
||||
result.undetermined[model] = note
|
||||
|
||||
result.available.sort()
|
||||
result.unavailable.sort()
|
||||
save_entitlements(result)
|
||||
return result
|
||||
|
|
@ -15,7 +15,7 @@ import threading
|
|||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
|
@ -114,6 +114,76 @@ class ProfileAuthManager:
|
|||
"""Official API to get isolated directory for a profile."""
|
||||
return get_profile_dir(profile_id, provider)
|
||||
|
||||
# agy 2.0 (Antigravity CLI) читает вход НЕ из .gemini/oauth_creds.json —
|
||||
# это формат Gemini CLI. Свой токен он держит в
|
||||
# .gemini/antigravity-cli/antigravity-oauth-token, и структура там другая:
|
||||
# обёртка {"auth_method": ..., "token": {...}}.
|
||||
#
|
||||
# Хаб писал только файл Gemini CLI, поэтому авторизация проходила успешно, а
|
||||
# agy отвечал «Please sign in to view available models»: он смотрел в файл,
|
||||
# которого нет. Установлено сравнением рабочего профиля с неработающим.
|
||||
# Значение взято из рабочего профиля владельца, не выведено из общих
|
||||
# соображений: agy пишет туда "consumer" для личного аккаунта Google.
|
||||
ANTIGRAVITY_AUTH_METHOD = "consumer"
|
||||
|
||||
@classmethod
|
||||
def _write_antigravity_cli_token(cls, profile_dir: Path, creds_dict: dict) -> Optional[Path]:
|
||||
"""Записать токен в формате Antigravity CLI рядом с файлом Gemini CLI."""
|
||||
cli_dir = profile_dir / ".gemini" / "antigravity-cli"
|
||||
target = cli_dir / "antigravity-oauth-token"
|
||||
try:
|
||||
cli_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(cli_dir, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Способ входа сохраняем такой же, как у уже работающего профиля на
|
||||
# этой машине: угадывать его значение нельзя, а рабочий образец
|
||||
# рядом — самый надёжный источник.
|
||||
auth_method = cls.ANTIGRAVITY_AUTH_METHOD
|
||||
try:
|
||||
for sibling in profile_dir.parent.iterdir():
|
||||
if sibling == profile_dir or not sibling.is_dir():
|
||||
continue
|
||||
ref = sibling / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||
if ref.is_file():
|
||||
existing = json.loads(ref.read_text(encoding="utf-8"))
|
||||
if isinstance(existing, dict) and existing.get("auth_method"):
|
||||
auth_method = str(existing["auth_method"])
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Срок годности пишем в обоих видах. Gemini CLI (Node) ждёт
|
||||
# expiry_date в миллисекундах, Go-шный oauth2.Token — expiry
|
||||
# строкой RFC3339. Какой из них читает agy, по бинарнику не
|
||||
# определить, а лишнее поле разбор JSON пропускает.
|
||||
token_payload = dict(creds_dict)
|
||||
try:
|
||||
expiry_ms = int(creds_dict.get("expiry_date") or 0)
|
||||
if expiry_ms > 0:
|
||||
token_payload["expiry"] = (
|
||||
datetime.fromtimestamp(expiry_ms / 1000, tz=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
pass
|
||||
|
||||
payload = {"auth_method": auth_method, "token": token_payload}
|
||||
temp = cli_dir / f"antigravity-oauth-token.tmp-{threading.get_ident()}-{time.time_ns()}"
|
||||
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(temp, target)
|
||||
try:
|
||||
os.chmod(target, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return target
|
||||
except Exception as exc:
|
||||
logger.error("Не записан токен Antigravity CLI в %s: %s", target, exc)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
|
||||
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
|
||||
|
|
@ -123,6 +193,14 @@ class ProfileAuthManager:
|
|||
|
||||
access_token = token_info.get("access_token") or auth_data.get("access_token") or ""
|
||||
refresh_token = token_info.get("refresh_token") or auth_data.get("refresh_token") or ""
|
||||
# Файл без токена доступа бесполезен: agy на нём отвечает «Please sign in
|
||||
# to view available models», а хаб считает аккаунт подключённым. Пустой
|
||||
# вход не должен выдаваться за успешный.
|
||||
if not access_token and not refresh_token:
|
||||
raise ValueError(
|
||||
"Вход не завершён: провайдер не вернул токен доступа. "
|
||||
"Учётные данные agy не записаны."
|
||||
)
|
||||
scope = token_info.get("scope") or auth_data.get("scope") or ""
|
||||
token_type = token_info.get("token_type") or auth_data.get("token_type") or "Bearer"
|
||||
id_token = token_info.get("id_token") or auth_data.get("id_token") or ""
|
||||
|
|
@ -179,6 +257,8 @@ class ProfileAuthManager:
|
|||
os.chmod(target_file, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cls._write_antigravity_cli_token(profile_dir, creds_dict)
|
||||
return target_file
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -314,7 +394,16 @@ class ProfileAuthManager:
|
|||
try:
|
||||
cls.write_agy_oauth_creds(pdir, auth_data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
|
||||
# Прежде сбой оставался только в журнале, и владелец видел
|
||||
# «подключено» при неработающем аккаунте. agy читает именно
|
||||
# этот файл, поэтому без него подключения нет.
|
||||
logger.error(
|
||||
"Не записаны учётные данные agy для профиля %s: %s", profile_id, e
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Учётные данные для agy не записаны ({e}). "
|
||||
f"Аккаунт {profile_id} подключённым не считается."
|
||||
) from e
|
||||
|
||||
# For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
|
||||
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
|
||||
|
|
|
|||
|
|
@ -255,6 +255,29 @@ class ProfileOAuthSession:
|
|||
email = fetch_user_email(tokens["access_token"])
|
||||
logger.info("OAuth account identity resolved (email_found=%s)", bool(email))
|
||||
|
||||
# Слот выбирается ДО входа, когда почта ещё неизвестна, поэтому
|
||||
# повторный вход тем же аккаунтом занимал очередной свободный
|
||||
# слот: у владельца один аккаунт расползся на ag-2, ag-3, ag-4.
|
||||
# Узнав почту, возвращаем учётные данные в слот, который этот
|
||||
# аккаунт уже занимает, вместо создания двойника.
|
||||
if email:
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
|
||||
try:
|
||||
existing = AutoAssigner.check_duplicate_identity(
|
||||
"antigravity", email, exclude_profile_id=self.profile_id
|
||||
)
|
||||
except Exception as exc:
|
||||
existing = None
|
||||
logger.warning("Проверка двойников не выполнена: %s", exc)
|
||||
if existing and existing != self.profile_id:
|
||||
logger.info(
|
||||
"Аккаунт уже занимает профиль %s — пишем туда, а не в %s",
|
||||
existing,
|
||||
self.profile_id,
|
||||
)
|
||||
self.profile_id = existing
|
||||
|
||||
# Format in standard gemini:antigravity shape
|
||||
expires_at = tokens.get("expires_at") or (int(time.time()) + 3600)
|
||||
auth_data = {
|
||||
|
|
|
|||
|
|
@ -918,6 +918,31 @@ class AccountQuotaService:
|
|||
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
|
||||
"""Truthful offline baseline with provider-specific independent limit pools."""
|
||||
now = _utc_now()
|
||||
# У облачного аккаунта Ollama лимиты есть, и объявлять их
|
||||
# отсутствующими нельзя. Локальный сервер узнаётся по отсутствию
|
||||
# ключа: облачный доступ без ключа невозможен.
|
||||
if provider == "ollama":
|
||||
try:
|
||||
from .profile_manager import ProfileAuthManager
|
||||
|
||||
auth = ProfileAuthManager.load_profile_auth(provider, profile_id) or {}
|
||||
is_cloud = bool((auth.get("api_key") or auth.get("token") or "").strip())
|
||||
except Exception:
|
||||
is_cloud = False
|
||||
if is_cloud:
|
||||
return QuotaSnapshot(
|
||||
account_id=profile_id,
|
||||
provider=provider,
|
||||
buckets=[],
|
||||
fetched_at=now,
|
||||
source="cloud_provider",
|
||||
unavailable_reason=(
|
||||
"Н/Д: лимиты облачного аккаунта Ollama не измерены — "
|
||||
"провайдер не сообщает их через API"
|
||||
),
|
||||
is_loading=False,
|
||||
)
|
||||
|
||||
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
||||
b_unlimited = QuotaBucket(
|
||||
id="local.unlimited",
|
||||
|
|
|
|||
|
|
@ -543,7 +543,25 @@ class UnifiedHealthService:
|
|||
if check.get("state") == "checking":
|
||||
health_state, health_lbl = "checking", "Проверяется…"
|
||||
elif check.get("state") == "failed":
|
||||
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д")
|
||||
# Вердикт проверки и каталог моделей обновляются
|
||||
# независимо, и в карточке владельца они оказались
|
||||
# рядом: «не работает — Please sign in» и тут же
|
||||
# «Получено 11 моделей» с более поздним временем.
|
||||
# Утверждать отказ, когда каталог получен ПОЗЖЕ него,
|
||||
# нельзя: провайдер с тех пор ответил. Но и объявлять
|
||||
# аккаунт рабочим не за что — проверка после этого не
|
||||
# выполнялась. Честный ответ — что вердикт устарел.
|
||||
checked_at = check.get("checked_at") or 0
|
||||
catalog_at = model_meta.get("discovered_at") or 0
|
||||
if not model_meta.get("error") and catalog_at > checked_at:
|
||||
when = datetime.datetime.fromtimestamp(catalog_at).strftime("%H:%M:%S")
|
||||
health_state = STATUS_NOT_TESTED
|
||||
health_lbl = (
|
||||
f"Проверка устарела: каталог моделей получен позже ({when}). "
|
||||
"Нажмите «Проверить подключение»."
|
||||
)
|
||||
else:
|
||||
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д")
|
||||
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
|
||||
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
|
||||
assigned = role_assignments.get(pid, [])
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from antigravity_provider.router.state_store import HubStateStore
|
|||
from antigravity_provider.router.action_handler import ActionExecutor
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
|
||||
from antigravity_provider.updater.update_manager import get_installed_commit, UpdateManager
|
||||
from antigravity_provider.updater.update_manager import get_installed_commit, get_installed_build_time, UpdateManager
|
||||
|
||||
logger = logging.getLogger("hermes.router.web")
|
||||
|
||||
|
|
@ -489,6 +489,10 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
|||
"config_dir": system_paths["config_dir"],
|
||||
"log_file": system_paths["log_file"],
|
||||
"installed_commit": get_installed_commit(),
|
||||
# Версия между сборками не меняется намеренно, а коммит — строка из
|
||||
# шестнадцатеричных цифр, по которой трудно на глаз отличить старую
|
||||
# сборку от новой. Время установки отвечает на этот вопрос сразу.
|
||||
"installed_at": get_installed_build_time(),
|
||||
"version": __version__,
|
||||
"last_update_check": last_check.to_dict() if last_check else None,
|
||||
"network_security": {
|
||||
|
|
@ -586,6 +590,23 @@ if _STATIC_DIR.is_dir():
|
|||
headers=_NO_CACHE,
|
||||
)
|
||||
|
||||
# A48 и A49 добавили новые файлы клиента, а заголовки кэширования им не
|
||||
# прописали: браузер держал их сколько угодно. Владелец обновлял сборку и
|
||||
# видел прежний интерфейс — ровно та поломка, ради которой запрет кэша
|
||||
# вводился для app.js. Перечисление сделано общим, чтобы следующий
|
||||
# добавленный файл не оказался снова без заголовков.
|
||||
for _client_script in ("workspace.js", "workflow.js", "workflow.css"):
|
||||
|
||||
def _make_route(name: str):
|
||||
media = "text/css" if name.endswith(".css") else "application/javascript"
|
||||
|
||||
def _serve():
|
||||
return FileResponse(str(_STATIC_DIR / name), media_type=media, headers=_NO_CACHE)
|
||||
|
||||
return _serve
|
||||
|
||||
app.get("/" + _client_script)(_make_route(_client_script))
|
||||
|
||||
@app.get("/snapshot.example.json")
|
||||
def _fixture():
|
||||
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json")
|
||||
|
|
|
|||
|
|
@ -570,15 +570,32 @@ async function executeAction(actionName, actionData = {}) {
|
|||
}
|
||||
|
||||
// ── GLOBAL HEADER ──
|
||||
// Версия между сборками не меняется намеренно, а коммит — строка из
|
||||
// шестнадцатеричных цифр. Дата установки отвечает на вопрос «старая сборка
|
||||
// загрузилась или новая» сразу и без сверки коммитов.
|
||||
function versionTagText(curVer) {
|
||||
const installedAt = currentSettings && currentSettings.installed_at;
|
||||
let stamp = '';
|
||||
if (installedAt) {
|
||||
const d = new Date(installedAt);
|
||||
if (!isNaN(d.getTime())) {
|
||||
stamp = ' · ' + d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
return curVer ? `Hermes Hub Web v${curVer}${stamp}` : 'Hermes Hub Web — Н/Д: версия не передана сервером';
|
||||
}
|
||||
|
||||
function updateGlobalHeader() {
|
||||
if (!currentSnapshot) return;
|
||||
|
||||
const readiness = currentSnapshot.readiness || {};
|
||||
renderAccountSummary(currentSnapshot);
|
||||
const allProfiles = Object.values(currentSnapshot.all_profiles || {});
|
||||
const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter(
|
||||
(p) => isConnectedProfile(p)
|
||||
).length;
|
||||
// Одно число — одно определение. Готовность считает строго AUTHENTICATED,
|
||||
// а страница аккаунтов — всё, что не NOT_CONFIGURED. Из-за двух определений
|
||||
// значок в меню показывал 9, а карточка на той же странице — 3.
|
||||
// Берём то же правило, что и страница аккаунтов: расходиться они не должны.
|
||||
const connectedAccounts = allProfiles.filter((p) => isConnectedProfile(p)).length;
|
||||
|
||||
if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts;
|
||||
|
||||
|
|
@ -629,7 +646,7 @@ function updateGlobalHeader() {
|
|||
const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || '';
|
||||
const versionTag = document.getElementById('version-tag');
|
||||
if (versionTag) {
|
||||
versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером';
|
||||
versionTag.textContent = versionTagText(curVer);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -878,9 +895,23 @@ function renderAccountCheck(profile) {
|
|||
<details ${models.length <= 16 ? 'open' : ''}><summary>Каталог моделей (${models.length})</summary><div class="account-models">${models.map(modelBrandLabel).join('')}</div></details>
|
||||
${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>
|
||||
${['nvidia', 'nvidia-nim', 'openrouter'].includes(String(profile.provider || '').toLowerCase())
|
||||
? `<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); handleProbeAccountModels('${escapeHtml(profile.profile_id)}', '${escapeHtml(profile.provider)}')" title="Каталог провайдера общий для всех и о правах аккаунта не сообщает. Доступность выясняется опросом моделей и расходует вызовы.">Определить доступные модели</button>`
|
||||
: ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Каталог NVIDIA и OpenRouter публичный: он одинаков у всех и о правах
|
||||
// аккаунта ничего не говорит. Доступность выясняется опросом моделей, а он
|
||||
// тратит вызовы — поэтому только по явному нажатию, и результат сохраняется.
|
||||
async function handleProbeAccountModels(profileId, provider) {
|
||||
if (!confirm('Хаб опросит каталог провайдера, чтобы выяснить, какие модели доступны этому аккаунту. Каталог общий для всех и о правах не сообщает, поэтому каждая модель проверяется отдельным запросом. Недоступная отвечает отказом и ничего не стоит, доступная расходует один токен. Продолжить?')) return;
|
||||
showToast('Опрос моделей начат. Это может занять около минуты.', 'info');
|
||||
const res = await executeAction('probe_account_models', { profile_id: profileId, provider: provider });
|
||||
showToast(res?.message || 'Нет ответа от сервера', res?.ok ? 'success' : 'error');
|
||||
await fetchSnapshot();
|
||||
}
|
||||
|
||||
async function handleAccountProbe(profileId) {
|
||||
await executeAction('check_account', {profile_id: profileId});
|
||||
await fetchSnapshot();
|
||||
|
|
@ -2434,7 +2465,7 @@ function renderUpdateUI() {
|
|||
}
|
||||
const versionTag = document.getElementById('version-tag');
|
||||
if (versionTag) {
|
||||
versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером';
|
||||
versionTag.textContent = versionTagText(curVer);
|
||||
}
|
||||
|
||||
if (statusBadge) {
|
||||
|
|
@ -3163,7 +3194,15 @@ async function proceedToWizardStep3(providerId) {
|
|||
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 || ''});
|
||||
// Ключ читаем из поля прямо сейчас, а не из глобальной переменной:
|
||||
// она переживает предыдущие попытки подключения и может оказаться пустой
|
||||
// или чужой. Провайдер тогда отвечает «Missing Authentication header» при
|
||||
// заполненном поле, и владелец не понимает, в чём дело.
|
||||
const liveToken = (tokenInput && tokenInput.value.trim()) || window._wiz_token || '';
|
||||
const liveBase = (baseInput && baseInput.value.trim()) || window._wiz_base_url || '';
|
||||
window._wiz_token = liveToken;
|
||||
window._wiz_base_url = liveBase;
|
||||
const result = await executeAction('validate_connection', {provider: providerId, token: liveToken, base_url: liveBase});
|
||||
if (!result?.ok) {
|
||||
if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера';
|
||||
return;
|
||||
|
|
@ -3250,7 +3289,10 @@ async function finishAddAccount(providerId) {
|
|||
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохранение аккаунта и запуск проверки. Опрос провайдера может занять до минуты на этап.</div>`;
|
||||
// Обещать «до минуты на этап» было неправдой: проверка Antigravity через
|
||||
// CLI занимала до четырёх минут, и мастер выглядел зависшим. Теперь
|
||||
// действие возвращается сразу, а проверка идёт в фоне.
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохраняем аккаунт…</div>`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Single Source of Truth for Hermes Hub Versioning."""
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.1.2"
|
||||
VERSION_INFO = (0, 1, 2)
|
||||
__version__ = "0.1.3"
|
||||
VERSION_INFO = (0, 1, 3)
|
||||
CHANNEL = "stable"
|
||||
MINIMUM_HERMES_VERSION = "0.20.0"
|
||||
|
||||
|
|
|
|||
|
|
@ -132,31 +132,35 @@ def test_p0_3_antigravity_dynamic_model_discovery():
|
|||
# ── P0-4: Version Info & API Propagation Tests ──
|
||||
|
||||
def test_p0_4_version_single_source_of_truth():
|
||||
"""Version must be 0.1.2 and VERSION_INFO must be (0, 1, 2)."""
|
||||
assert __version__ == "0.1.2"
|
||||
assert VERSION_INFO == (0, 1, 2)
|
||||
"""Номер версии и VERSION_INFO обязаны совпадать между собой.
|
||||
|
||||
Сверять с записанным в тесте числом бессмысленно: при каждой сборке его
|
||||
пришлось бы править, и тест превращался бы в напоминание, а не в проверку.
|
||||
Смысл требования — единый источник, его и проверяем.
|
||||
"""
|
||||
assert VERSION_INFO == tuple(int(part) for part in __version__.split("."))
|
||||
|
||||
|
||||
def test_p0_4_version_in_api_endpoints():
|
||||
"""API endpoints /api/snapshot, /api/health, and /api/settings must return version 0.1.2."""
|
||||
"""Все точки API обязаны отдавать ту же версию, что и пакет."""
|
||||
client = TestClient(app)
|
||||
|
||||
# /api/health
|
||||
res_health = client.get("/api/health")
|
||||
assert res_health.status_code == 200
|
||||
assert res_health.json().get("version") == "0.1.2"
|
||||
assert res_health.json().get("version") == __version__
|
||||
|
||||
# /api/settings
|
||||
res_settings = client.get("/api/settings")
|
||||
assert res_settings.status_code == 200
|
||||
assert res_settings.json().get("version") == "0.1.2"
|
||||
assert res_settings.json().get("version") == __version__
|
||||
|
||||
# /api/snapshot
|
||||
res_snap = client.get("/api/snapshot")
|
||||
assert res_snap.status_code == 200
|
||||
snap = res_snap.json()
|
||||
assert snap.get("version") == "0.1.2"
|
||||
assert (snap.get("metrics") or {}).get("version") == "0.1.2"
|
||||
assert snap.get("version") == __version__
|
||||
assert (snap.get("metrics") or {}).get("version") == __version__
|
||||
|
||||
|
||||
# ── P0-5: Settings View & System Paths Tests ──
|
||||
|
|
|
|||
77
tests/test_add_account_does_not_block.py
Normal file
77
tests/test_add_account_does_not_block.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Подключение аккаунта не должно ждать опроса провайдера.
|
||||
|
||||
Мастер владельца замирал на шаге 3 с надписью «сохранение аккаунта и запуск
|
||||
проверки». Зависанием это не было: действие честно ждало проверку Antigravity
|
||||
через CLI — до 90 с на захват замка профиля, до 65 на каталог моделей и до 90
|
||||
на пробный вызов. Около четырёх минут молчания при обещанной «минуте на этап».
|
||||
|
||||
Сохранение и назначение роли занимают миллисекунды. Опрос провайдера идёт в
|
||||
фоне, а карточка обновляется, когда он закончится.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||
from antigravity_provider.router.action_handler import ActionExecutor
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
|
||||
def _connect_authenticated_antigravity():
|
||||
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True}), \
|
||||
patch.object(ProfileAuthManager, "load_profile_auth", return_value={"email": "x@gmail.com"}), \
|
||||
patch.object(ProfileAuthManager, "save_profile_auth", return_value="/tmp/auth.json"), \
|
||||
patch.object(AutoAssigner, "ensure_profile_definition", return_value=(True, "ok")), \
|
||||
patch.object(AutoAssigner, "assign_profile_to_role", return_value=(True, "ok")):
|
||||
return ActionExecutor.execute(
|
||||
"add_account",
|
||||
{"provider": "antigravity", "profile_id": "ag-1", "target_role": "orchestrator"},
|
||||
)
|
||||
|
||||
|
||||
def test_connect_returns_without_waiting_for_provider():
|
||||
slow_call = []
|
||||
|
||||
def _slow_check_now(self, provider, profile_id, models_only=False):
|
||||
slow_call.append(profile_id)
|
||||
time.sleep(5) # изображаем опрос провайдера
|
||||
return {"ok": True, "message": "проверено"}
|
||||
|
||||
with patch.object(AccountProbeService, "schedule", return_value=True) as scheduled, \
|
||||
patch.object(AccountProbeService, "check_now", _slow_check_now):
|
||||
started = time.monotonic()
|
||||
res = _connect_authenticated_antigravity()
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert res["ok"], res
|
||||
assert not slow_call, "действие не должно ждать опрос провайдера"
|
||||
assert scheduled.called, "проверка обязана быть поставлена в фон"
|
||||
assert elapsed < 2, f"ответ занял {elapsed:.1f} с"
|
||||
assert res["data"]["check"] == "running"
|
||||
|
||||
|
||||
def test_result_reaches_owner_when_background_service_is_down():
|
||||
"""Служба не работает — проверяем здесь, иначе результата не будет вовсе."""
|
||||
with patch.object(AccountProbeService, "schedule", return_value=False), \
|
||||
patch.object(AccountProbeService, "state", return_value={"state": "never_checked"}), \
|
||||
patch.object(
|
||||
AccountProbeService,
|
||||
"check_now",
|
||||
return_value={"ok": False, "message": "провайдер отказал", "data": {}},
|
||||
) as checked:
|
||||
res = _connect_authenticated_antigravity()
|
||||
|
||||
assert checked.called, "без фоновой службы проверка выполняется на месте"
|
||||
assert res["message"] == "провайдер отказал"
|
||||
|
||||
|
||||
def test_check_already_running_is_not_awaited():
|
||||
with patch.object(AccountProbeService, "schedule", return_value=False), \
|
||||
patch.object(AccountProbeService, "state", return_value={"state": "checking"}), \
|
||||
patch.object(AccountProbeService, "check_now") as checked:
|
||||
res = _connect_authenticated_antigravity()
|
||||
|
||||
assert not checked.called, "уже идущую проверку не ждём и не дублируем"
|
||||
assert res["ok"] and res["data"]["check"] == "running"
|
||||
96
tests/test_agy_cli_token_file.py
Normal file
96
tests/test_agy_cli_token_file.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""agy 2.0 читает вход не из oauth_creds.json.
|
||||
|
||||
Сравнение рабочего профиля владельца с неработающим показало единственное
|
||||
значимое различие: у рабочего есть `.gemini/antigravity-cli/antigravity-oauth-token`,
|
||||
у неработающего — только `.gemini/oauth_creds.json`. Оба файла с токенами были
|
||||
на месте и одинакового размера, но `agy models` отвечал «Please sign in to view
|
||||
available models».
|
||||
|
||||
Формат подтверждён на машине владельца: верхний уровень — ключи `auth_method`
|
||||
и `token`, значение `auth_method` — `consumer`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
|
||||
def _write(tmp_path):
|
||||
return ProfileAuthManager.write_agy_oauth_creds(
|
||||
tmp_path,
|
||||
{
|
||||
"access_token": "ya29.TEST-ACCESS",
|
||||
"refresh_token": "1//TEST-REFRESH",
|
||||
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||
"token_type": "Bearer",
|
||||
"id_token": "eyJTEST",
|
||||
"expiry_date": int((time.time() + 3600) * 1000),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_antigravity_cli_token_written(tmp_path):
|
||||
_write(tmp_path)
|
||||
target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||
assert target.is_file(), "agy читает вход именно отсюда"
|
||||
|
||||
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert sorted(payload) == ["auth_method", "token"]
|
||||
assert payload["auth_method"] == "consumer"
|
||||
assert payload["token"]["access_token"] == "ya29.TEST-ACCESS"
|
||||
assert payload["token"]["refresh_token"] == "1//TEST-REFRESH"
|
||||
|
||||
|
||||
def test_expiry_written_in_both_formats(tmp_path):
|
||||
_write(tmp_path)
|
||||
target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||
token = json.loads(target.read_text(encoding="utf-8"))["token"]
|
||||
# Node-формат — миллисекунды, Go-шный oauth2.Token — строка RFC3339.
|
||||
assert isinstance(token["expiry_date"], int)
|
||||
assert token["expiry"].endswith("Z")
|
||||
|
||||
|
||||
def test_gemini_creds_still_written(tmp_path):
|
||||
"""Прежний файл остаётся: его читают другие части agy и сам хаб."""
|
||||
target_file = _write(tmp_path)
|
||||
assert target_file == tmp_path / ".gemini" / "oauth_creds.json"
|
||||
creds = json.loads(target_file.read_text(encoding="utf-8"))
|
||||
assert creds["access_token"] == "ya29.TEST-ACCESS"
|
||||
assert "auth_method" not in creds
|
||||
|
||||
|
||||
def test_auth_method_taken_from_working_neighbour(tmp_path):
|
||||
"""Значение способа входа берётся у уже работающего профиля, если он есть."""
|
||||
neighbour = tmp_path / "ag-working" / ".gemini" / "antigravity-cli"
|
||||
neighbour.mkdir(parents=True)
|
||||
(neighbour / "antigravity-oauth-token").write_text(
|
||||
json.dumps({"auth_method": "workforce", "token": {}}), encoding="utf-8"
|
||||
)
|
||||
|
||||
profile = tmp_path / "ag-new"
|
||||
profile.mkdir()
|
||||
_write(profile)
|
||||
|
||||
payload = json.loads(
|
||||
(profile / ".gemini" / "antigravity-cli" / "antigravity-oauth-token").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert payload["auth_method"] == "workforce"
|
||||
|
||||
|
||||
def test_empty_login_writes_nothing(tmp_path):
|
||||
"""Вход без токена не должен оставлять ни одного файла учётных данных."""
|
||||
try:
|
||||
ProfileAuthManager.write_agy_oauth_creds(tmp_path, {"email": "x@y.z"})
|
||||
except ValueError:
|
||||
pass
|
||||
else: # pragma: no cover - защита от возврата прежнего поведения
|
||||
raise AssertionError("пустой вход обязан отказывать")
|
||||
|
||||
assert not (tmp_path / ".gemini" / "oauth_creds.json").exists()
|
||||
assert not (
|
||||
tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||
).exists()
|
||||
|
|
@ -72,3 +72,50 @@ def test_silent_installer_fails_without_hermes(tmp_path):
|
|||
|
||||
res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True)
|
||||
assert res.returncode != 0, f"Expected failure for missing Hermes, got {res.returncode}"
|
||||
|
||||
|
||||
# ── Установка на Linux: sudo и защищённый системный Python ──
|
||||
#
|
||||
# Установка на сервере владельца упала так: запуск через sudo увёл всё в
|
||||
# /root/.hermes, венв Hermes там не нашёлся, установщик взял /usr/bin/python3,
|
||||
# а он в Ubuntu 24.04 помечен EXTERNALLY-MANAGED и отклоняет pip. Проверка
|
||||
# после установки честно упала на «No module named 'fastapi'».
|
||||
|
||||
def _linux_installer_text() -> str:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
return (root / "installer" / "install-linux.sh").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_linux_installer_refuses_sudo():
|
||||
text = _linux_installer_text()
|
||||
assert "SUDO_USER" in text, "запуск через sudo обязан распознаваться"
|
||||
assert "HERMES_ALLOW_ROOT" in text, "должен быть осознанный способ обойти отказ"
|
||||
assert "exit 3" in text
|
||||
|
||||
|
||||
def test_linux_installer_creates_own_venv_when_system_python_is_managed():
|
||||
text = _linux_installer_text()
|
||||
assert "EXTERNALLY-MANAGED" in text, "PEP 668 должен распознаваться"
|
||||
assert "-m venv" in text, "ответ на PEP 668 — собственное окружение"
|
||||
# --break-system-packages ломает питон всей машины: имя флага не
|
||||
# преувеличивает. Упоминание в пояснении допустимо, применение — нет,
|
||||
# поэтому смотрим только на исполняемые строки.
|
||||
code_lines = [
|
||||
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||
]
|
||||
assert not any("--break-system-packages" in line for line in code_lines)
|
||||
|
||||
|
||||
def test_linux_installer_fails_loudly_on_missing_deps():
|
||||
"""Раньше pip падал, а установка продолжалась — до отказа на проверке."""
|
||||
text = _linux_installer_text()
|
||||
assert "exit 12" in text and "exit 13" in text
|
||||
assert "|| true" not in text.split("Installing required packages")[1][:400]
|
||||
|
||||
|
||||
def test_linux_launcher_knows_about_installer_venv():
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
launcher = (root / "launcher" / "hermes-hub-web.sh").read_text(encoding="utf-8")
|
||||
assert '$HERMES_HOME/venv/bin/python3' in launcher, (
|
||||
"иначе запуск уйдёт на системный python, где зависимостей нет"
|
||||
)
|
||||
|
|
|
|||
118
tests/test_stale_verdict_vs_catalog.py
Normal file
118
tests/test_stale_verdict_vs_catalog.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Вердикт проверки не должен переживать более свежий каталог моделей.
|
||||
|
||||
В карточке владельца рядом стояли «Проверен: не работает — Please sign in» и
|
||||
«Получено 11 моделей · 13:25:53». Источники разные и обновляются независимо:
|
||||
красная строка берётся из состояния проверки, список — из кэша каталога.
|
||||
Каталог был получен ПОЗЖЕ отказа, то есть провайдер с тех пор ответил.
|
||||
|
||||
Утверждать отказ в такой ситуации нельзя. Объявлять аккаунт рабочим — тоже
|
||||
не за что: проверка после этого не выполнялась. Честный ответ один: вердикт
|
||||
устарел, нужна новая проверка.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.router_config import (
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
save_router_config,
|
||||
)
|
||||
from antigravity_provider.router.unified_health import (
|
||||
STATUS_NOT_TESTED,
|
||||
STATUS_UNHEALTHY,
|
||||
UnifiedHealthService,
|
||||
)
|
||||
|
||||
|
||||
def _prepare(failed_at: float, catalog_at: float | None, catalog_error=None):
|
||||
cfg = RouterConfig()
|
||||
cfg.profiles["ag-5"] = RouterProfileConfig(
|
||||
profile_id="ag-5",
|
||||
provider="antigravity",
|
||||
account_id="ag-5",
|
||||
preferred_models=[],
|
||||
enabled=True,
|
||||
)
|
||||
save_router_config(cfg)
|
||||
ProfileAuthManager.save_profile_auth(
|
||||
"antigravity",
|
||||
"ag-5",
|
||||
{
|
||||
# Запись входа Antigravity по праву отказывает без токена доступа,
|
||||
# поэтому в проверке он должен быть настоящим по форме.
|
||||
"token": {
|
||||
"access_token": "ya29.TEST",
|
||||
"refresh_token": "1//TEST",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": int((time.time() + 3600) * 1000),
|
||||
},
|
||||
"email": "owner@gmail.com",
|
||||
"auth_method": "oauth",
|
||||
},
|
||||
)
|
||||
|
||||
probe = AccountProbeService.get()
|
||||
with probe._lock:
|
||||
probe._states["ag-5"] = {
|
||||
"state": "failed",
|
||||
"provider": "antigravity",
|
||||
"checked_at": failed_at,
|
||||
"message": "agy models: код 1. Please sign in to view available models.",
|
||||
}
|
||||
|
||||
discovery = ModelDiscoveryService.get()
|
||||
with discovery._cache_lock:
|
||||
discovery._cache["antigravity:ag-5"] = {
|
||||
"models": ["gemini-3.1-pro-high", "claude-sonnet-4-6"],
|
||||
"discovered_at": catalog_at,
|
||||
"error": catalog_error,
|
||||
}
|
||||
return UnifiedHealthService.get()
|
||||
|
||||
|
||||
def _view(service):
|
||||
profiles = service.scan_all(force=True)
|
||||
return next(p for p in profiles["antigravity"] if p.profile_id == "ag-5")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_newer_catalog_makes_the_failed_verdict_stale(tmp_path):
|
||||
now = time.time()
|
||||
view = _view(_prepare(failed_at=now - 60, catalog_at=now - 20))
|
||||
|
||||
assert view.health_state == STATUS_NOT_TESTED
|
||||
assert "устарела" in view.health_label_ru
|
||||
assert "Please sign in" not in view.health_label_ru
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_failure_stands_when_it_is_the_newer_fact(tmp_path):
|
||||
now = time.time()
|
||||
view = _view(_prepare(failed_at=now - 10, catalog_at=now - 300))
|
||||
|
||||
assert view.health_state == STATUS_UNHEALTHY
|
||||
assert "Please sign in" in view.health_label_ru
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_failure_stands_when_catalog_itself_errored(tmp_path):
|
||||
now = time.time()
|
||||
view = _view(
|
||||
_prepare(failed_at=now - 60, catalog_at=now - 20, catalog_error="Сервер отказал")
|
||||
)
|
||||
|
||||
assert view.health_state == STATUS_UNHEALTHY
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_failure_stands_when_catalog_was_never_obtained(tmp_path):
|
||||
now = time.time()
|
||||
view = _view(_prepare(failed_at=now - 60, catalog_at=None))
|
||||
|
||||
assert view.health_state == STATUS_UNHEALTHY
|
||||
Loading…
Reference in a new issue