Fix account slot isolation and add background checks with per-account model discovery

This commit is contained in:
ochenstarik-ui 2026-08-31 18:42:41 +07:00
parent 1fbbeacd13
commit 79ac9cf561
24 changed files with 901 additions and 72 deletions

View file

@ -0,0 +1,183 @@
# Задание A50: аккаунты, обнаружение моделей и состояние проверки
## Дата поступления
2026-08-31
## База
`origin/main` (`17b368a`).
```
git fetch origin --prune
git checkout -b antigravity/a50-accounts-discovery origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
Зона: Python-часть провайдеров и экран «Аккаунты». С A49 (субагенты, скиллы, память) не пересекается.
---
## Задача
Восемь замечаний владельца после установки сборки `17b368a`. Причины найдены и проверены ревьюером исполнением — заново не выяснять.
---
## P0-1. Аккаунт сохраняется в чужой слот и рапортует об успехе
Самое серьёзное. Проверено вызовом:
```
add_account provider=nvidia profile_id=ag-w1 token=k
→ ok=True «Сервер nvidia (ag-w1) успешно подключен»
```
Аккаунт NVIDIA записан в слот Antigravity. Бэкенд **не проверяет, что слот принадлежит провайдеру**.
Как владелец в это попадает: в мастере для OpenRouter и NVIDIA список слотов пуст — `buildSlotOptions` возвращает «Список слотов ещё не получен». Дальше в `app.js` слот выбирается так:
```js
selectedProfileId = window._wiz_device_profile ?? (deviceSlot?.value || redirectSlot?.value || '');
```
`??` пропускает пустую строку, поэтому побеждает `_wiz_device_profile`, оставшийся **от предыдущей попытки подключения другого провайдера**. Владелец пробовал Antigravity, потом NVIDIA — и NVIDIA легла в `ag-w1`.
Отсюда жалобы 3 и 4: «нвидиа не добавляется», «опенроутер так же не добавляется». Он добавляется — не туда.
Требуется:
1. **Бэкенд отклоняет чужой слот.** `profile_id`, не принадлежащий провайдеру, — отказ с причиной, а не `ok: True`.
2. **Состояние мастера сбрасывается** при возврате к выбору провайдера и при открытии нового подключения. Остатков от прошлой попытки быть не должно.
3. **Список слотов для OpenRouter и NVIDIA** заполняется или поле не показывается вовсе, раз слот выдаётся автоматически.
4. **Прогнать сквозной путь** для обоих провайдеров с заведомо неверным ключом: профиль создаётся с правильным идентификатором, проверка подключения даёт внятную ошибку авторизации.
## P0-2. Вернуть разделение по провайдерам
Владелец: «нет разделения по аккаунтам. Как раньше: Антигравити и снизу все аккаунты аги, Грок и снизу все аккаунты грока».
Разметка группировки цела (`provider-group`, `provider-group-header`, счётчик). Её **скрыл A48**, добавив в `style.css`:
```css
.provider-group,.accounts-grid { display:contents; }
.provider-group-header { display:none; }
```
Вернуть группы: заголовок провайдера, его значок, число аккаунтов, под ним карточки. Вёрстку A48 в остальном не ломать — это правка одного места в стилях, а не переделка экрана.
## P0-3. Проверка должна запускаться сама
Владелец: «везде пишет Статус: Не проверялся и, я так понимаю, ничего не работает».
Ярлык честен, но вывод владельца неверен, и это вина интерфейса. Проверено:
```
unified_health.py:499 precord.last_success is None → «Не проверялся»
server.py, hermes_hub_app.py — вызова проверки при запуске НЕТ
```
То есть состояние меняется только после **успешного вызова через профиль**, а вызвать его автоматически некому. Пока владелец не нажмёт «Проверить подключение» вручную для каждого аккаунта, все останутся «Не проверялся» навсегда.
Требуется:
1. **Проверка запускается сама**: сразу после подключения аккаунта и периодически. Период настраивается, значение по умолчанию обосновать.
2. **Не блокировать интерфейс**: проверка идёт в фоне, состояние обновляется по мере готовности.
3. **Различать три состояния явно**: «не проверялся», «проверяется», «проверен: работает / не работает с причиной». Сейчас первое и третье сливаются в одно.
4. **Кнопка ручной проверки остаётся** — и для отдельного аккаунта, и для всех сразу.
## P0-4. Списки моделей не подтягиваются ни у одного аккаунта
Та же причина: обнаружение запускается только по явному действию. У Grok, Antigravity и Ollama владелец видит «Список моделей ещё не получен от провайдера».
1. **Запрашивать список при подключении** аккаунта и при периодической проверке.
2. **Кэшировать** с временем получения; показывать, когда список снят.
3. **Ошибка обнаружения доходит до интерфейса с текстом ответа сервера.** «Не получен» и «сервер отказал: <текст>» — разные сообщения.
4. **Кнопка «Запросить список моделей» показывает ход** и результат, а не остаётся в прежнем виде.
## P0-5. Облачные модели Ollama
Ветка обнаружения Ollama после A42 читает адрес из профиля и опрашивает `/api/tags`. Это **только локально скачанные модели**; облачных там нет по устройству эндпоинта.
1. Выяснить **по действующей документации Ollama**, как получить список облачных моделей учётной записи и что для этого нужно. **Эндпоинт не выдумывать.**
2. Показывать локальные и облачные раздельно, чтобы владелец видел, что откуда.
3. Способ не подтверждён документацией — так и написать в отчёте, а в интерфейсе показать `Н/Д` с причиной. Это принимается; выдуманный адрес — нет.
## P0-6. Долгая загрузка данных аккаунта
Владелец: «у Грока и Антигравити долго подгружаются данные аккаунтов… чтобы не начали по несколько раз подключать один аккаунт».
Измеренные пределы ожидания:
```
quota_collector таймауты 15, 20 и 30 секунд на запрос
agy_subprocess таймаут 60 секунд — Antigravity ходит через CLI agy
```
То есть до минуты ожидания — это штатное поведение, а не поломка. Проблема в том, что владелец этого не видит.
1. **Показывать ход**: «идёт опрос провайдера, это может занять до минуты» с указанием, какой именно аккаунт опрашивается.
2. **Не давать запустить подключение того же аккаунта повторно**, пока предыдущее не завершилось.
3. **По истечении ожидания** — внятное сообщение с причиной и предложением повторить, а не молчание.
4. Ускорять там, где это возможно без риска: параллельный опрос независимых аккаунтов вместо последовательного. Если ускорение невозможно — так и написать, честное объяснение задержки достаточно.
## P0-7. Локальный сервер называть тем, что там работает
Владелец: «локальный сервер это llama, так и надо подписывать».
Сейчас `auto_assigner.py:83` даёт провайдеру `local` подпись «Локальный сервер», а профилям — «Локальный сервер 1» и «Локальный сервер 2».
1. Провайдер `local` подписывать по движку: `llama.cpp`. Для `ollama` и `vllm` подписи уже свои — не трогать.
2. **Если движок определяется по ответу сервера** — брать оттуда. Иначе подпись по типу профиля, без выдумок.
3. Проверить, что переименование не ломает сохранённые конфигурации: идентификаторы профилей не меняются, меняется только отображаемое имя.
## P0-8. Аудит вторым проходом
1. **Проверить чужой слот целенаправленно**: подключить NVIDIA после начатой и брошенной попытки Antigravity. Аккаунт обязан лечь в свой слот.
2. **Убедиться, что мнимых успехов не осталось**: ни одно действие, положившее данные не туда, не возвращает `ok: True`.
3. **Открыть экран «Аккаунты»** и увидеть группы по провайдерам. Скриншот приложить.
4. **Дождаться автоматической проверки** и убедиться, что состояние сменилось само, без нажатий.
5. **Проверить, что «не проверялся» и «проверен, не работает» различимы** на экране.
6. **Эндпоинт облачных моделей Ollama** сверить с документацией.
7. **Побочные изменения** объяснить.
8. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Ключи владельца не запрашивать и в репозиторий не класть.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Вёрстку A48 не переделывать: по группам — правка стилей, не переработка экрана.
- Службы `qwen-coder` и `qwen-compressor` не трогать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений: не измерено — `Н/Д` с причиной.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Аккаунт не сохраняется в слот чужого провайдера; попытка отклоняется с причиной; проверено.
3. Состояние мастера сбрасывается между попытками; проверено сквозным путём.
4. OpenRouter и NVIDIA подключаются с правильными идентификаторами профилей.
5. На экране «Аккаунты» вернулись группы по провайдерам; скриншот приложен.
6. Проверка запускается автоматически после подключения и периодически; состояние меняется без ручных нажатий.
7. «Не проверялся», «проверяется» и «проверен, не работает» различимы.
8. Списки моделей подтягиваются автоматически; ошибка доходит с текстом сервера.
9. Облачные модели Ollama получены либо честно объявлены недоступными с причиной.
10. Долгая загрузка показывает ход; повторный запуск того же подключения невозможен.
11. Локальный провайдер подписан по движку.
12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
13. Память проекта в AI-Memory обновлена.
14. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец смотрит на четыре подключённых аккаунта, у всех «Не проверялся», ни у одного нет списка моделей, и делает единственно возможный вывод: ничего не работает. На деле проверка просто ни разу не запускалась, потому что запускать её некому.
Рядом дефект, который хуже: аккаунт NVIDIA сохраняется в слот Antigravity и докладывает об успехе. Владелец видит, что аккаунт «не добавился», и пробует снова — а в конфигурации накапливается мусор.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

66
docs/reports/A50.md Normal file
View file

@ -0,0 +1,66 @@
# A50 — аккаунты, обнаружение моделей и проверка подключения
Дата: 2026-08-31. Исполнитель: Codex.
**Статус: реализация и проверки исполнителя готовы; независимый проход Pro не выполнен.** Flash и Pro недоступны среди моделей этой сессии. Замена независимого аудитора на Codex запрошена у владельца, ответа пока нет. Не считать этот отчёт независимым аудитом или разрешением на релиз.
## База и область изменений
- START_HEAD / origin/main: `17b368a155fde354ece0b0fa65aaefa6eb5db1fe`.
- Ветка: `antigravity/a50-accounts-discovery`, отдельный worktree.
- Логотипы: отдельный коммит `1fbbeacd1345bb028b735e58d5468ac52a750193`, [источники и лицензии](model-provider-logos.md).
- FINAL_HEAD и состояние origin фиксируются после финального коммита в отчёте передачи и worklog AI-Memory. Собственный SHA файла отчёта не записывается внутрь этого же коммита.
- A49, граф и алгоритм исполнения субагентов не изменялись. Версия осталась 0.1.1. Службы моделей и пользовательские учётные данные не изменялись.
## Что изменено
1. **Слот аккаунта.** Центральная проверка отвергает чужие зарезервированные префиксы, конфликт с провайдером существующего профиля и небезопасные идентификаторы до записи. Применяется к регистрации профиля, прямому добавлению и началу OAuth. При исчерпании слотов нет отката к занятому `provider-1`.
2. **Мастер.** Состояние очищается при возврате к списку и смене провайдера; пустой выбор явно сохраняется. Запоздалый ответ начала OAuth другого провайдера игнорируется. По умолчанию предлагается новый свободный слот; занятые можно выбрать явно. Повторное сохранение блокируется в UI; повтор того же подключения во время проверки блокируется сервером.
3. **Группы.** Восстановлены заголовок с логотипом, счётчик и сетка карточек. Убрана фиксированная высота карточки: иначе новая диагностика и длинные ошибки обрезали квоты. Это необходимая локальная поправка, не переделка A48.
4. **Фоновая проверка.** При запуске веб-сервера, завершении подключения и периодически проверяются подключённые включённые профили. До четырёх независимых аккаунтов одновременно, один опрос на профиль. Состояния: ещё не проверялся → проверяется → работает либо ошибка с причиной. Снимок обновляется при начале, получении моделей и завершении. Есть ручные кнопки одного аккаунта и всех аккаунтов.
5. **Период.** `account_check_interval_seconds`, 300 секунд по умолчанию, минимум 60. Значение читается в настройках из `/api/settings`, а не изображается полученным из снапшота. Пять минут ограничивают частоту платных тестовых запросов и повторных OAuth/CLI вызовов, сохраняя достаточно свежую диагностику. Проверка отправляет короткий запрос с `max_tokens=1`; предупреждение о расходе квоты есть в настройках.
6. **Модели.** Дисковый кэш дополнен ключом провайдер + профиль. Discovery выбирает именно этот аккаунт, включая Antigravity. Показаны время получения и ошибка сервера; при ошибке старый список и время сохраняются. Успешный пустой список отделён от неудачи. Ручная кнопка блокируется на время опроса и показывает ход.
7. **Медленные операции.** Проверка модели допускает до 60 секунд; обнаружение — отдельный этап до 20 секунд. UI предупреждает о минуте на этап и показывает профиль. Синхронные обработчики действий вынесены из event loop HTTP-сервера; медленное добавление не блокирует `/api/health` и чтение состояния.
8. **Название движка.** `local` отображается как `llama.cpp`; идентификаторы `local-1`/`local-2` не меняются. Ollama и vLLM не переименованы.
## Ollama: подтверждённая документация
[Официальная документация Cloud](https://docs.ollama.com/cloud) прямо документирует `GET https://ollama.com/api/tags` для облачного каталога. Это отличается от `/api/tags` локального хоста. Прямой вызов облачных моделей требует API-ключа Ollama; локальный клиент использует `ollama signin`.
Каталог получен реальным публичным запросом без ключей: **19 моделей**, ошибка отсутствует, 2026-08-31. В приложении он кэшируется отдельно от моделей сервера профиля. Каталог не доказывает индивидуальное право аккаунта на инференс: UI сообщает **Н/Д до успешного вызова**. Неподтверждённый endpoint личных разрешений не добавлен. При недоступности сети — Нс причиной.
## Проверки исполнителя
- Python: **535 passed / 1 skipped / 0 failed**, ещё 4 deselected штатным фильтром `not live and not network and not installer`.
- Skip: `test_windows_csharp_launchers_and_setup_compile` — на Linux нет Windows `csc.exe`.
- `ruff check .`: чисто; `git diff --check`: чисто.
- `verify_multi_provider_router.py`: **10/10**, 0 errors, 0 warnings.
- `release_gate.py`: **PASSED**, включая полную suite, P0, updater/rollback, версии и проверки секретов.
- JS: `test_web_handlers_dom_contract.js`, `test_workspace_a48.js`, `test_brand_icons.js` — пройдены.
- 18 регрессий A50: чужие/небезопасные слоты; реальный loopback HTTP 401 для NVIDIA и OpenRouter; дедупликация; состояние проверки; периодический запуск; раздельные кэши; сохранение времени и текста ошибки; документированный cloud URL без передачи авторизации; переименование без изменения ID; отзывчивость HTTP при медленном действии.
## Браузерные свидетельства
Стенд `tests/manual/a50_preview.py` использует отдельный временный каталог данных, синтетические аккаунты `A50-TEST-*` и настоящий loopback HTTP-провайдер. Это **не замеры аккаунтов владельца**. OAuth, квоты и обновления в fixture отключены. Воспроизводится командой `PYTHONPATH=src python tests/manual/a50_preview.py` (порты 5803 и 5813).
- Без ручного запуска увидены `Проверяется…`, затем два работающих и два отказавших аккаунта с HTTP 401; появились модели с временем получения.
- Мастер: выбор Antigravity → назад → NVIDIA → неверный тестовый ключ → правильный NVIDIA-профиль и явный 401. Дополнительный JS-тест намеренно сохраняет `ag-w1` до переключения и проверяет очистку для обоих провайдеров.
- Мастер OpenRouter: автоматический новый `openrouter-2`, затем фоновый 401.
- Период в настройках прочитан с сервера: 300, поле доступно для изменения.
- Логотипы визуально проверены в Dark / Medium / Light; 16 локальных изображений без битых ссылок.
Снимки: [в процессе](../screenshots/a50/accounts-checking.png), [результат](../screenshots/a50/accounts-checked.png), [NVIDIA Dark](../screenshots/a50/accounts-nvidia-dark.png), [Medium](../screenshots/a50/accounts-nvidia-medium.png), [Light](../screenshots/a50/accounts-nvidia-light.png), [OpenRouter после мастера](../screenshots/a50/openrouter-wizard-result.png), [набор логотипов](../screenshots/a50/brand-catalog.png).
Первые два снимка сделаны до поправки фиксированной высоты карточек; окончательная вёрстка показана на трёх снимках NVIDIA.
## Оставшиеся ограничения / пункты P0-8
- **Независимый второй проход Pro пропущен:** модель недоступна. Реализация сделана Codex, а не Flash; все проверки выше — проверки самого исполнителя.
- Состояние `не проверялся` проверено тестом до запуска worker. Отдельный браузерный снимок одновременно `не проверялся` и `проверен: не работает` не снят: стартовый автоматический опрос сразу забирает подключённые профили.
- Периодический повтор подтверждён детерминированным тестом часов; пять минут в браузере отдельно не выжидались.
- Производственные OAuth аккаунты владельца, Windows installer и установленная сборка не проверялись/не обновлялись. Превью 5801 не заменялось.
- Таймаут ожидания не отменяет уже отправленный провайдеру запрос: адаптер завершит собственный сетевой timeout. Повтор самого фонового задания не допускается, пока оно имеет статус `checking`; после сообщения о timeout повтор разрешён.
- Фоновая служба запускается жизненным циклом веб-сервера. Отдельный вызов ActionExecutor вне запущенного сервера сохраняет аккаунт, но честно сообщает, что служба проверки не запущена.
- API-ответы не превращаются в подтверждение работоспособности при одном только сохранении: сообщение говорит «сохранён», результат проверки показывается отдельно.
Дополнительная правка теста A47: проверка памяти больше не требует навечно зафиксированный `80aab00`, а сверяет SHA, реально записанный в CURRENT_STATE. После обновления общей памяти другой задачей старая константа давала ложное падение при корректной памяти. Проверки существования коммита и свежести сохранены; код памяти A49 не менялся.

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -0,0 +1,94 @@
"""Non-blocking, de-duplicated health and model probes for configured accounts."""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional
class AccountProbeService:
_instance: Optional["AccountProbeService"] = None
_singleton_lock = threading.Lock()
def __init__(self) -> None:
self.enabled = False
self._next_check = 0.0
self._states: dict[str, dict[str, Any]] = {}
self._lock = threading.Lock()
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
@classmethod
def get(cls) -> "AccountProbeService":
with cls._singleton_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def state(self, profile_id: str) -> dict[str, Any]:
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
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._pool.submit(self._run, provider, profile_id)
return True
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)
def schedule_all(self, *, force: bool = False) -> int:
from .profile_manager import ProfileAuthManager
from .router_config import load_router_config
count = 0
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))
return count
def _run(self, provider: str, profile_id: str) -> None:
from .action_handler import do_test_profile
from .model_discovery_service import ModelDiscoveryService
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 "Проверка завершена без пояснения"
state = "working" if success else "failed"
except Exception as exc:
models, meta, state, message = None, {}, "failed", str(exc)
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

View file

@ -35,7 +35,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
)
return ok, msg
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, discovered_models: Optional[List[str]] = None) -> Dict[str, Any]:
valid, reason = AutoAssigner.validate_slot(provider, profile_id)
if not valid:
return {"success": False, "error": reason}
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
@ -52,7 +55,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
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 'default'
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
t0 = time.time()
try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
@ -78,7 +81,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
t = threading.Thread(target=_call_invoke, daemon=True)
t.start()
t.join(timeout=10.0)
t.join(timeout=timeout)
el = round(time.time() - t0, 2)
@ -86,7 +89,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
return {
'success': False,
'duration_sec': el,
'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)',
'error': f'Превышено время ожидания ответа от провайдера ({timeout:g}с). Повторите проверку.',
}
if error_container:
@ -239,7 +242,7 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.model_registry import ModelRegistry
discovered = ModelDiscoveryService.get().get_models(provider)
discovered = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id).get("models") or ModelDiscoveryService.get().get_models(provider)
if discovered is None:
try:
discovered = ModelDiscoveryService.get().discover_models_sync(provider, timeout=5.0)
@ -337,6 +340,8 @@ def _rescan_after_auth() -> None:
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)
@ -533,8 +538,30 @@ def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]:
class ActionExecutor:
"""Shared execution layer for Desktop and Web actions."""
_connect_lock = threading.Lock()
_pending_connections: Dict[str, str] = {}
@classmethod
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
if action != "add_account":
return cls._execute(action, data, async_runner, actor)
import hashlib
from .account_probe_service import AccountProbeService
fingerprint = hashlib.sha256(json.dumps([
data.get("provider"), data.get("token") or data.get("api_key"), data.get("base_url")
]).encode()).hexdigest()
with cls._connect_lock:
cls._pending_connections = {key: pid for key, pid in cls._pending_connections.items() if AccountProbeService.get().state(pid).get("state") == "checking"}
previous = cls._pending_connections.get(fingerprint)
if previous and AccountProbeService.get().state(previous).get("state") == "checking":
return {"ok": False, "message": f"Аккаунт {previous} уже сохранён и проверяется. Дождитесь результата."}
result = cls._execute(action, data, async_runner, actor)
if result.get("ok"):
cls._pending_connections[fingerprint] = result["data"]["profile_id"]
return result
@classmethod
def _execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
"""
Execute the specified action.
If async_runner is provided, long actions will be dispatched to it.
@ -602,6 +629,9 @@ class ActionExecutor:
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
if not slot:
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
valid, reason = AutoAssigner.validate_slot(provider, slot)
if not valid:
return {'ok': False, 'message': reason}
try:
if provider == 'grok':
from antigravity_provider.router.grok_oauth import start_grok_oauth
@ -697,7 +727,9 @@ class ActionExecutor:
else:
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
slot = slot or AutoAssigner.find_free_slot(prov_norm)
if not slot:
return {'ok': False, 'message': 'Нет свободного слота'}
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
if not ok:
return {'ok': False, 'message': def_msg}
@ -716,7 +748,10 @@ class ActionExecutor:
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth()
return {'ok': True, 'message': f'Аккаунт {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}}
except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
@ -740,6 +775,9 @@ class ActionExecutor:
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
if not slot:
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
valid, reason = AutoAssigner.validate_slot(provider, slot)
if not valid:
return {'ok': False, 'message': reason}
try:
if provider == 'antigravity':
from antigravity_provider.router.profile_oauth import (
@ -857,8 +895,7 @@ class ActionExecutor:
elif action == 'test':
if async_runner:
async_runner(lambda: do_test_profile(prov, pid), 'TestProfile')
return {'ok': True, 'message': 'запущено'}
return cls._execute('check_account', {'provider': prov, 'profile_id': pid}, async_runner, actor)
else:
res = do_test_profile(prov, pid)
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
@ -931,6 +968,23 @@ class ActionExecutor:
elif action == 'refresh_data':
return {'ok': True, 'message': 'Обновление данных'}
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 'Проверка уже выполняется'}
elif action == 'check_all_accounts':
from antigravity_provider.router.account_probe_service import AccountProbeService
if not AccountProbeService.get().enabled:
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
count = AccountProbeService.get().schedule_all(force=True)
return {'ok': True, 'message': f'Запущена проверка {count} аккаунтов'}
elif action == 'refresh_all':
if async_runner:
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')

View file

@ -47,8 +47,8 @@ DEFAULT_SLOT_ROLES = {
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
"local-1": ("Локальный сервер 1", "coder", "primary"),
"local-2": ("Локальный сервер 2", "fast", "primary"),
"local-1": ("llama.cpp 1", "coder", "primary"),
"local-2": ("llama.cpp 2", "fast", "primary"),
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
@ -80,7 +80,7 @@ class AutoAssigner:
"opengo": "OpenCode",
"claude": "Claude",
"grok": "Grok",
"local": "Локальный сервер",
"local": "llama.cpp",
"openrouter": "OpenRouter",
"nvidia": "NVIDIA NIM",
"ollama": "Ollama",
@ -181,6 +181,34 @@ class AutoAssigner:
return None
@staticmethod
def validate_slot(provider: str, profile_id: str) -> Tuple[bool, str]:
"""Reject foreign and unsafe slots before any auth/config mutation."""
import re
aliases = {
"antigravity": "ag", "google-antigravity": "ag", "agy": "ag",
"openai-codex": "codex", "codex": "codex", "openai": "codex",
"opencode-go": "opengo", "opencode": "opengo",
"anthropic": "claude", "claude": "claude", "xai": "grok", "grok": "grok",
"local": "local", "local-llm": "local", "llama.cpp": "local",
"nvidia-nim": "nvidia", "nvidia": "nvidia",
"openrouter": "openrouter", "ollama": "ollama", "vllm": "vllm",
}
owner = aliases.get(provider.strip().lower())
if not owner or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", profile_id):
return False, "Недопустимый провайдер или идентификатор слота"
reserved = profile_id.split("-", 1)[0]
if reserved in set(aliases.values()) and reserved != owner:
return False, f"Слот {profile_id} не принадлежит провайдеру {provider}"
existing = load_router_config().get_profile(profile_id)
if existing:
valid = aliases.get(existing.provider.lower()) == owner
else:
valid = profile_id.startswith(owner + "-")
if not valid:
return False, f"Слот {profile_id} не принадлежит провайдеру {provider}"
return True, ""
@staticmethod
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
"""Persist a router profile for provider slots introduced by the UI.
@ -189,6 +217,9 @@ class AutoAssigner:
If discovery has not run yet, preferred_models remains empty [] instead
of inventing unsupported model literals.
"""
valid, reason = AutoAssigner.validate_slot(provider, profile_id)
if not valid:
return False, reason
config = load_router_config()
if profile_id in config.profiles:
return True, "Профиль уже зарегистрирован"

View file

@ -35,6 +35,7 @@ class ModelDiscoveryService:
self._cache_path = cache_path
self._cache_lock = threading.Lock()
self._cache: Dict[str, Dict[str, Any]] = {}
self._probe_context = threading.local()
self._ttl_seconds: int = 3600 # 1 hour
self._load_cache_from_disk()
@ -82,10 +83,11 @@ class ModelDiscoveryService:
return None
return list(meta["models"])
def get_models_with_metadata(self, provider: str) -> Dict[str, Any]:
def get_models_with_metadata(self, provider: str, profile_id: Optional[str] = None) -> Dict[str, Any]:
"""Return cached models and freshness status without blocking."""
with self._cache_lock:
entry = self._cache.get(provider.lower())
key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower()
entry = self._cache.get(key)
if not entry or "models" not in entry:
return {
"provider": provider,
@ -101,10 +103,10 @@ class ModelDiscoveryService:
models = entry.get("models")
return {
"provider": provider,
"models": list(models) if models else None,
"models": list(models) if models is not None else None,
"discovered_at": discovered_at,
"is_stale": is_stale,
"has_cache": bool(models),
"has_cache": models is not None,
"error": entry.get("error"),
}
@ -188,13 +190,15 @@ class ModelDiscoveryService:
threading.Thread(target=_worker, daemon=True).start()
def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]:
def discover_models_sync(self, provider: str, timeout: float = 15.0, profile_id: Optional[str] = None) -> Optional[List[str]]:
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
cache_key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower()
result_holder: List[Optional[List[str]]] = [None]
error_holder: List[Optional[str]] = [None]
def _do_probe():
try:
self._probe_context.profile_id = profile_id
models, err_msg = self._probe_provider(provider)
result_holder[0] = models
error_holder[0] = err_msg
@ -209,9 +213,9 @@ class ModelDiscoveryService:
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
with self._cache_lock:
entry = self._cache.get(provider.lower(), {})
entry = self._cache.get(cache_key, {})
existing_models = entry.get("models")
self._cache[provider.lower()] = {
self._cache[cache_key] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": timeout_msg,
@ -222,9 +226,9 @@ class ModelDiscoveryService:
models = result_holder[0]
err_text = error_holder[0]
if models:
if models is not None and not err_text:
with self._cache_lock:
self._cache[provider.lower()] = {
self._cache[cache_key] = {
"models": models,
"discovered_at": time.time(),
"error": None,
@ -236,9 +240,9 @@ class ModelDiscoveryService:
if err_text:
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
with self._cache_lock:
entry = self._cache.get(provider.lower(), {})
entry = self._cache.get(cache_key, {})
existing_models = entry.get("models")
self._cache[provider.lower()] = {
self._cache[cache_key] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": err_text,
@ -247,9 +251,9 @@ class ModelDiscoveryService:
return list(existing_models) if existing_models else None
with self._cache_lock:
entry = self._cache.get(provider.lower(), {})
entry = self._cache.get(cache_key, {})
existing_models = entry.get("models")
self._cache[provider.lower()] = {
self._cache[cache_key] = {
"models": existing_models,
"discovered_at": entry.get("discovered_at"),
"error": entry.get("error") or "Модели не найдены",
@ -257,9 +261,37 @@ class ModelDiscoveryService:
self._save_cache_to_disk()
return list(existing_models) if existing_models else None
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
def discover_ollama_cloud(self) -> Dict[str, Any]:
"""Public catalog documented at https://docs.ollama.com/cloud#listing-models.
Catalog presence is not proof of an account's inference entitlement.
"""
key = "ollama-cloud-catalog"
error = None
models = None
try:
raw_err = http_err.read().decode("utf-8", errors="replace")
req = urllib.request.Request("https://ollama.com/api/tags", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
models = sorted({str(m.get("name") or m.get("model")) for m in data.get("models", []) if isinstance(m, dict) and (m.get("name") or m.get("model"))})
except urllib.error.HTTPError as exc:
error = self._extract_http_error(exc)
except Exception as exc:
error = str(exc)
with self._cache_lock:
previous = self._cache.get(key, {})
self._cache[key] = {
"models": models if models is not None else previous.get("models"),
"discovered_at": time.time() if models is not None else previous.get("discovered_at"),
"error": error,
}
self._save_cache_to_disk()
return self.get_models_with_metadata(key)
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
raw_err = ""
try:
raw_err = http_err.read().decode("utf-8", errors="replace")[:2000]
err_json = json.loads(raw_err)
if isinstance(err_json, dict):
if "error" in err_json:
@ -278,12 +310,16 @@ class ModelDiscoveryService:
msg = raw_err
return f"HTTP {http_err.code}: {msg}"
except Exception:
return f"HTTP {http_err.code}: {http_err.reason}"
return f"HTTP {http_err.code}: {raw_err or http_err.reason}"
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
from antigravity_provider.router.router_config import load_router_config
cfg = load_router_config()
p_lower = prov.lower()
requested = getattr(self._probe_context, "profile_id", None)
if requested:
pcfg = cfg.get_profile(requested)
return [(requested, pcfg)] if pcfg else []
matched = [
(pid, pcfg)
for pid, pcfg in cfg.profiles.items()
@ -326,7 +362,7 @@ class ModelDiscoveryService:
if prov in ("antigravity", "google-antigravity"):
from antigravity_provider.agy_subprocess import discover_models
main_p = ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
try:
res = discover_models(profile_id=main_p)
if res:
@ -614,8 +650,7 @@ class ModelDiscoveryService:
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
if name:
models.append(str(name))
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("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
@ -635,8 +670,7 @@ class ModelDiscoveryService:
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
if mid:
models.append(str(mid))
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("Ollama /v1/models HTTP error on %s: %s", pid, last_err)

View file

@ -19,6 +19,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"auto_update": True,
"release_channel": "stable",
"model_timeout_seconds": 60,
"account_check_interval_seconds": 300,
"monitoring_interval_seconds": 30,
"quota_threshold_percent": 10.0,
"quota_threshold_action": "notify",
@ -75,6 +76,11 @@ def get_hub_settings() -> Dict[str, Any]:
except Exception:
pass
try:
merged["account_check_interval_seconds"] = max(60, int(merged.get("account_check_interval_seconds", 300)))
except (ValueError, TypeError):
merged["account_check_interval_seconds"] = 300
# Normalize numeric types
try:
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))

View file

@ -97,6 +97,8 @@ class ProfileViewModel:
plan_source: str = "unknown"
quota_snapshot: Optional[Any] = None
preferred_models: List[str] = field(default_factory=list)
connection_check: Dict[str, Any] = field(default_factory=dict)
model_discovery: Dict[str, Any] = field(default_factory=dict)
active_leases: int = 0
request_options: dict[str, Any] = field(default_factory=dict)
@ -518,15 +520,30 @@ class UnifiedHealthService:
"nvidia": "NVIDIA NIM",
"nvidia-nim": "NVIDIA NIM",
"ollama": "Ollama",
"local": "Local LLM",
"local-llm": "Local LLM",
"llama.cpp": "Local LLM (llama.cpp)",
"local": "llama.cpp",
"local-llm": "llama.cpp",
"llama.cpp": "llama.cpp",
"vllm": "vLLM",
}.get(prov.lower(), prov)
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
from .account_probe_service import AccountProbeService
from .model_discovery_service import ModelDiscoveryService
check = AccountProbeService.get().state(pid)
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 check.get("state") == "checking":
health_state, health_lbl = "checking", "Проверяется…"
elif check.get("state") == "failed":
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, "Проверен: работает"
vm = ProfileViewModel(
connection_check=check,
model_discovery=model_meta,
profile_id=pid,
display_name=display_name,
account_identity=ident.primary_identifier() if is_authenticated else identity,
@ -541,7 +558,7 @@ class UnifiedHealthService:
health_label_ru=health_lbl,
model_states=model_states,
cooldown_remaining_sec=max_cd,
last_checked_at=now_str,
last_checked_at=datetime.datetime.fromtimestamp(check["checked_at"]).isoformat() if check.get("checked_at") else None,
last_success_at=last_success_str,
enabled=pcfg.enabled,
is_cold_spare=is_cold,

View file

@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder
from starlette.concurrency import run_in_threadpool
from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.action_handler import ActionExecutor
@ -225,7 +226,7 @@ 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 = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner, actor=actor)
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
if result.get("unknown"):
raise HTTPException(status_code=404, detail="Неизвестное действие")
@ -412,6 +413,9 @@ _SNAPSHOT_REFRESH_SEC = 30
def _background_refresh_loop() -> None:
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.account_probe_service import AccountProbeService
AccountProbeService.get().enabled = True
AccountProbeService.get().tick()
try:
AccountQuotaService.get().fetch_all_configured(force=True)
@ -428,6 +432,7 @@ def _background_refresh_loop() -> None:
while True:
try:
AccountProbeService.get().tick()
HubStateStore.get().refresh(force_scan=False)
except Exception as exc:
logger.warning("Snapshot refresh failed: %s", exc)

View file

@ -637,9 +637,11 @@ function renderAccountsView() {
'opencode-go': 'OpenCode Go',
claude: 'Claude (Anthropic)',
grok: 'Grok (xAI)',
local: 'Local LLM',
'local-llm': 'Local LLM',
'llama.cpp': 'Local LLM (llama.cpp)',
nvidia: 'NVIDIA NIM',
openrouter: 'OpenRouter',
local: 'llama.cpp',
'local-llm': 'llama.cpp',
'llama.cpp': 'llama.cpp',
ollama: 'Ollama',
vllm: 'vLLM',
};
@ -647,7 +649,7 @@ function renderAccountsView() {
const profilesByProv = currentSnapshot.profiles_by_provider || {};
let totalProfiles = 0;
let visibleProfiles = 0;
let html = '';
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button></div>';
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
@ -695,7 +697,7 @@ function renderAccountsView() {
<div class="provider-group">
<div class="provider-group-header">
<div class="provider-group-title">
<span class="provider-dot" style="color: var(--prov-${providerId.replace('openai-', '').replace('-go', '')})"></span>
<img class="brand-logo" src="/static/${getProviderIcon(providerId)}" width="24" height="24" alt="">
<span>${providerNames[providerId] || providerId}</span>
</div>
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
@ -788,11 +790,33 @@ function renderAccountCard(profile) {
</div>
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
${renderAccountCheck(profile)}
${quotaGridHtml}
</div>
`;
}
function renderAccountCheck(profile) {
const check = profile.connection_check || {};
const meta = profile.model_discovery || {};
const checking = check.state === 'checking';
const models = meta.models || [];
const timestamp = meta.discovered_at ? new Date(meta.discovered_at * 1000).toLocaleString('ru-RU') : '';
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(modelStatus)}</p>
<div class="account-models">${models.slice(0, 8).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>`;
}
async function handleAccountProbe(profileId) {
await executeAction('check_account', {profile_id: profileId});
await fetchSnapshot();
}
function renderQuotaCell(bucket, unavailableReason) {
const remaining = bucket.remaining_percent;
let formattedValue = 'Н/Д';
@ -1474,6 +1498,7 @@ function renderSettingsView() {
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const accountIntervalInput = document.getElementById('setting-account-check-interval');
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
@ -1484,6 +1509,17 @@ function renderSettingsView() {
if (emailMaskingSel && s.email_masking_mode) {
emailMaskingSel.value = s.email_masking_mode;
}
if (accountIntervalInput && !accountIntervalInput.dataset.loaded) {
accountIntervalInput.dataset.loaded = 'loading';
fetch('/api/settings', {headers: authToken ? {'X-Hub-Token': authToken} : {}})
.then(response => { if (!response.ok) throw new Error('Настройки недоступны'); return response.json(); })
.then(settings => {
if (!Number.isFinite(Number(settings.account_check_interval_seconds))) throw new Error('Период не передан сервером');
accountIntervalInput.value = settings.account_check_interval_seconds;
accountIntervalInput.disabled = false;
accountIntervalInput.dataset.loaded = 'yes';
}).catch(error => { accountIntervalInput.placeholder = 'Н/Д: ' + error.message; accountIntervalInput.dataset.loaded = ''; });
}
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
monitorIntervalInput.value = s.monitoring_interval_seconds;
}
@ -1494,8 +1530,10 @@ async function saveHubServerSettings() {
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const accountIntervalInput = document.getElementById('setting-account-check-interval');
const newSettings = {};
if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
@ -1673,7 +1711,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const qs = profile.quota_snapshot;
const buckets = (qs && qs.buckets) ? qs.buckets : [];
@ -1695,13 +1733,15 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')" ${profile.connection_check?.state === 'checking' ? 'disabled' : ''}>${profile.connection_check?.state === 'checking' ? 'Запрашивается список моделей…' : '↻ Запросить список моделей'}</button>
</div>
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
// Local request options section
let requestOptionsHtml = '';
if (profile.provider === 'local') {
@ -1756,6 +1796,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
@ -2267,7 +2308,7 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
async function handleRefreshProviderModels(providerId, profileId = null) {
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
const res = await executeAction('refresh_models', { provider: providerId });
const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' });
if (res && res.ok) {
showToast('Запрос обновления моделей отправлен', 'success');
if (profileId) {
@ -2288,7 +2329,7 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const qs = profile.quota_snapshot;
const buckets = (qs && qs.buckets) ? qs.buckets : [];
@ -2310,13 +2351,14 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')" ${profile.connection_check?.state === 'checking' ? 'disabled' : ''}>${profile.connection_check?.state === 'checking' ? 'Запрашивается список моделей…' : '↻ Запросить список моделей'}</button>
</div>
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
@ -2400,7 +2442,7 @@ function openAgentModelModal(roleId, profileId) {
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
@ -2420,7 +2462,7 @@ function openAgentModelModal(roleId, profileId) {
` : `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')"> Запросить список моделей</button>
</div>
@ -2475,10 +2517,10 @@ async function handleTestProfile(profileId) {
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
}
const res = await executeAction('test', { profile_id: profileId });
const res = await executeAction('check_account', { profile_id: profileId });
if (feedbackArea) {
if (res && res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Тест успешно пройден')}</div>`;
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Проверка запущена; результат появится в карточке')}</div>`;
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
}
@ -2500,6 +2542,10 @@ function openAddAccountWizard() {
}
function showWizardStep1() {
stopDeviceAuthPolling();
stopRedirectAuthPolling();
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined;
window._wiz_provider = undefined;
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
elements.modalBody.innerHTML = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
@ -2577,6 +2623,12 @@ function showWizardStep1() {
}
function showWizardStep2(providerId) {
if (window._wiz_provider !== providerId) {
window._wiz_device_profile = undefined;
window._wiz_base_url = undefined;
window._wiz_token = undefined;
}
window._wiz_provider = providerId;
let bodyHtml = '';
let footerHtml = '';
@ -2752,14 +2804,10 @@ function proceedToWizardStep3(providerId) {
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
if (isDeviceAuthFlow) {
const deviceSlot = document.getElementById('wiz-device-slot');
if (deviceSlot && deviceSlot.value) {
window._wiz_device_profile = deviceSlot.value;
}
window._wiz_device_profile = deviceSlot?.value || '';
} else if (isRedirectAuthFlow) {
const redirectSlot = document.getElementById('wiz-redirect-slot');
if (redirectSlot && redirectSlot.value) {
window._wiz_device_profile = redirectSlot.value;
}
window._wiz_device_profile = redirectSlot?.value || '';
}
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
showWizardStep3(providerId);
@ -2811,6 +2859,10 @@ function showWizardStep3(providerId) {
}
async function finishAddAccount(providerId) {
if (window._wiz_saving) return;
window._wiz_saving = true;
const finishButton = elements.modalFooter?.querySelector(".btn-primary");
if (finishButton) finishButton.disabled = true;
const roleSelect = document.getElementById('wiz-target-role');
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
@ -2831,7 +2883,7 @@ async function finishAddAccount(providerId) {
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохранение аккаунта и запуск проверки. Опрос провайдера может занять до минуты на этап.</div>`;
}
const payload = {
@ -2847,10 +2899,12 @@ async function finishAddAccount(providerId) {
payload.token = window._wiz_token;
}
const res = await executeAction('add_account', payload);
let res;
try { res = await executeAction('add_account', payload); }
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
if (res && res.ok) {
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
closeModal();
fetchSnapshot();
} else {
@ -2888,6 +2942,7 @@ async function startDeviceAuth(providerId) {
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
if (window._wiz_provider !== providerId) return;
if (!res || !res.ok) {
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
return;
@ -2967,7 +3022,7 @@ function profilesInRouting() {
function buildSlotOptions(providerId) {
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
if (!profiles.length) {
return '<option value="">Список слотов ещё не получен</option>';
return '<option value="">Новый свободный слот — автоматически</option>';
}
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
// ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый
@ -2992,7 +3047,7 @@ function buildSlotOptions(providerId) {
else used.push(opt);
});
// Свободные слоты с ролью — первыми: именно они дают работающий маршрут.
return free.concat(used, idle).join('');
return '<option value="">Новый свободный слот — автоматически</option>' + free.concat(used, idle).join('');
}
let _redirectAuthTimer = null;
@ -3030,6 +3085,7 @@ async function startRedirectAuth(providerId) {
provider: providerId,
profile_id: chosen || undefined,
});
if (window._wiz_provider !== providerId) return;
if (!res || !res.ok) {
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
return;

View file

@ -387,6 +387,13 @@
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label" for="setting-account-check-interval">Проверка аккаунтов и моделей</label>
<div class="setting-desc">Каждые 300 секунд по умолчанию. Проверка отправляет короткий запрос модели и может расходовать квоту.</div>
</div>
<div class="setting-control"><input id="setting-account-check-interval" class="input-text" type="number" min="60" disabled placeholder="Н/Д: загрузка" aria-label="Интервал проверки аккаунтов, секунды"></div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Интервал обновления квот</div>

View file

@ -1823,9 +1823,14 @@ body[data-theme="medium"] .nav-icon { stroke:var(--canvas-ink); }
.account-summary strong { display:block; font-size:24px; font-weight:500; margin:6px 0; }
.account-summary span,.account-summary small { color:var(--text-muted); font-size:11px; }
.accounts-groups-container { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.provider-group,.accounts-grid { display:contents; }
.provider-group-header { display:none; }
.account-card { min-width:0; padding:16px; }
.provider-group { grid-column:1 / -1; min-width:0; }
.provider-group-header { display:flex; justify-content:space-between; align-items:center; margin:12px 0; }
.accounts-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr)); gap:12px; }
.account-card { min-width:0; padding:16px; height:auto; min-height:164px; gap:10px; }
.account-card-header { flex-wrap:wrap; }
.account-badges { min-width:0; flex-wrap:wrap; }
.account-card .badge-status { white-space:normal; overflow-wrap:anywhere; }
.badge-status.unhealthy { color:var(--status-error); }
.account-card-header { gap:8px; }
.account-provider-tag { display:flex; align-items:center; gap:10px; }
.account-provider-tag img { width:30px; height:30px; object-fit:contain; }
@ -1924,3 +1929,6 @@ body[data-theme="medium"] .nav-item.active .nav-icon { stroke:var(--text-accent)
.route-model-with-logo { display:flex; align-items:center; gap:6px; min-width:0; }
.route-model-with-logo select { min-width:0; }
.agent-node-icon .brand-logo { width:27px; height:27px; }
.account-check { font-size:12px; line-height:1.5; color:var(--text-secondary); overflow-wrap:anywhere; }
.badge-status.checking { color:var(--text-accent); }

View file

@ -200,7 +200,7 @@ function arrangeSettingsPanels() {
const first = view.querySelector('.settings-card');
const groups = [
['Общие настройки',['setting-theme']],
['Управление квотами',['setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
];
for (const [title,ids] of groups) {

View file

@ -0,0 +1,50 @@
"""Isolated UI preview with synthetic accounts and a loopback HTTP provider.
Run with PYTHONPATH=src python tests/manual/a50_preview.py. No owner data used.
"""
import json
import os
import tempfile
import threading
import time
from types import SimpleNamespace
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
os.environ["HERMES_HOME"] = tempfile.mkdtemp(prefix="a50-ui-test-")
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.web import server
class FixtureProvider(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"data":[{"id":"qwen3:fixture"}]}')
def do_POST(self):
time.sleep(20)
healthy = "/working/" in self.path
self.send_response(200 if healthy else 401)
self.end_headers()
data = {"choices": [{"message": {"content": "fixture OK"}}]} if healthy else {"error": {"message": "Invalid API key — A50 test fixture"}}
self.wfile.write(json.dumps(data).encode())
def log_message(self, *args):
pass
http = ThreadingHTTPServer(("127.0.0.1", 5813), FixtureProvider)
threading.Thread(target=http.serve_forever, daemon=True).start()
save_router_config(RouterConfig())
for provider, pid, mode in [("nvidia", "nvidia-1", "failed"), ("nvidia", "nvidia-2", "working"), ("openrouter", "openrouter-1", "failed"), ("local", "local-1", "working")]:
AutoAssigner.ensure_profile_definition(provider, pid)
ProfileAuthManager.save_profile_auth(provider, pid, {"api_key": "intentionally-invalid-a50-fixture", "base_url": f"http://127.0.0.1:5813/{mode}/v1", "email": f"A50-TEST-{pid}"})
# No quota/OAuth/update network in this isolated UI fixture.
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="Обновления отключены на тестовом стенде A50", update_available=False, to_dict=lambda: {})
server.run_web_server(host="127.0.0.1", port=5803)

View file

@ -111,7 +111,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 +137,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

199
tests/test_accounts_a50.py Normal file
View file

@ -0,0 +1,199 @@
"""A50 regressions: real local HTTP rejection and isolated account configuration."""
import io
import json
import threading
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from antigravity_provider.router.account_probe_service import AccountProbeService
from antigravity_provider.router.action_handler import ActionExecutor, do_test_profile
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.router_config import RouterConfig, load_router_config, save_router_config
@pytest.fixture
def isolated(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
save_router_config(RouterConfig())
service = AccountProbeService()
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.state_store.HubStateStore.refresh", lambda *a, **kw: None)
ActionExecutor._pending_connections.clear()
yield service, discovery
service._pool.shutdown(wait=True)
@pytest.mark.parametrize("provider", ["nvidia", "openrouter", "grok", "claude"])
def test_foreign_slot_rejected_before_write(isolated, provider):
result = ActionExecutor.execute("add_account", {"provider": provider, "profile_id": "ag-w1", "token": "intentionally-invalid"})
assert not result["ok"]
assert "не принадлежит" in result["message"]
assert not load_router_config().profiles
@pytest.mark.parametrize("slot", ["../ag-w1", "/tmp/slot", "nvidia-1/evil"])
def test_unsafe_slot_rejected(isolated, slot):
assert not AutoAssigner.ensure_profile_definition("nvidia", slot)[0]
@pytest.mark.parametrize("provider", ["nvidia", "openrouter"])
def test_invalid_key_real_http_correct_slot(isolated, provider):
class Reject(BaseHTTPRequestHandler):
def do_POST(self):
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": {"message": "Invalid API key (A50 fixture)"}}).encode())
def do_GET(self):
self.do_POST()
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Reject)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
result = ActionExecutor.execute("add_account", {
"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"]
finally:
server.shutdown()
server.server_close()
thread.join()
def test_parallel_probe_dedup_and_failure_state(isolated, monkeypatch):
service, discovery = isolated
service.enabled = True
entered = threading.Event()
release = threading.Event()
def probe(*args, **kwargs):
entered.set()
assert release.wait(2)
return {"success": False, "error": "HTTP 401: Invalid API key"}
monkeypatch.setattr("antigravity_provider.router.action_handler.do_test_profile", probe)
monkeypatch.setattr(discovery, "discover_models_sync", lambda *a, **kw: [])
assert service.state("nvidia-1")["state"] == "never_checked"
assert service.schedule("nvidia", "nvidia-1")
assert entered.wait(2)
assert service.state("nvidia-1")["state"] == "checking"
assert not service.schedule("nvidia", "nvidia-1", force=True)
release.set()
service._pool.shutdown(wait=True)
assert service.state("nvidia-1")["state"] == "failed"
assert "401" in service.state("nvidia-1")["message"]
assert service.state("nvidia-1")["checked_at"]
def test_model_caches_are_account_scoped(isolated, monkeypatch):
_, service = isolated
monkeypatch.setattr(service, "_probe_provider", lambda p: ([service._probe_context.profile_id], None))
service.discover_models_sync("grok", profile_id="grok-1")
service.discover_models_sync("grok", profile_id="grok-2")
assert service.get_models_with_metadata("grok", "grok-1")["models"] == ["grok-1"]
assert service.get_models_with_metadata("grok", "grok-2")["models"] == ["grok-2"]
def test_model_error_retains_timestamped_cache(isolated, monkeypatch):
_, service = isolated
monkeypatch.setattr(service, "_probe_provider", lambda p: (["known-model"], None))
service.discover_models_sync("grok", profile_id="grok-1")
timestamp = service.get_models_with_metadata("grok", "grok-1")["discovered_at"]
monkeypatch.setattr(service, "_probe_provider", lambda p: (None, "HTTP 403: account refused"))
service.discover_models_sync("grok", profile_id="grok-1")
cached = service.get_models_with_metadata("grok", "grok-1")
assert cached["discovered_at"] == timestamp
assert cached["models"] == ["known-model"]
assert "403" in cached["error"]
def test_cloud_catalog_documented_url_no_credentials(isolated, monkeypatch):
_, service = isolated
def urlopen(request, **kwargs):
assert request.full_url == "https://ollama.com/api/tags"
assert not request.has_header("Authorization")
return io.BytesIO(b'{"models":[{"name":"test-cloud"}]}')
monkeypatch.setattr("urllib.request.urlopen", urlopen)
result = service.discover_ollama_cloud()
assert result["models"] == ["test-cloud"]
assert result["discovered_at"]
def test_llama_cpp_label_does_not_rename_id(isolated):
assert AutoAssigner.ensure_profile_definition("local", "local-1")[0]
assert AutoAssigner.get_display_name_and_role("local-1")[0] == "llama.cpp 1"
assert load_router_config().get_profile("local-1").provider == "local"
def test_periodic_checks_start_automatically_and_respect_interval(isolated, monkeypatch):
service, _ = isolated
service.enabled = True
calls = []
monkeypatch.setattr(service, "schedule_all", lambda **kw: calls.append(kw) or 1)
assert service.tick(now=1000) == 1
assert service.tick(now=1001) == 0
assert service.tick(now=1299) == 0
assert service.tick(now=1300) == 1
assert calls == [{"force": True}, {"force": True}]
def test_http_plaintext_error_preserved(isolated):
_, service = isolated
error = urllib.error.HTTPError("https://example.invalid/models", 403, "Forbidden", {}, io.BytesIO(b"Account disabled by provider"))
assert service._extract_http_error(error) == "HTTP 403: Account disabled by provider"
def test_slow_action_does_not_block_web_health(isolated, monkeypatch):
import asyncio
import httpx
from antigravity_provider.router.web.server import app
entered = threading.Event()
release = threading.Event()
def slow_action(*args, **kwargs):
entered.set()
release.wait(2)
return {"ok": True, "message": "test fixture"}
monkeypatch.setattr(ActionExecutor, "execute", slow_action)
async def exercise():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
pending = asyncio.create_task(client.post("/api/action", json={"action": "add_account"}))
try:
assert await asyncio.to_thread(entered.wait, 1)
response = await client.get("/api/health")
assert response.status_code == 200
assert not pending.done(), "A slow account action blocked the event loop"
finally:
release.set()
await pending
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"]

View file

@ -32,7 +32,8 @@ def test_check_memory_freshness_real_repo():
)
assert is_fresh is True
assert "FRESH" in summary
assert "80aab00" in summary
recorded = extract_recorded_commit(canonical_memory.read_text(encoding="utf-8"))
assert recorded and recorded[:7] in summary
def test_check_memory_freshness_missing_file_strict_vs_non_strict(tmp_path):

View file

@ -305,6 +305,24 @@ async function runTests() {
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
assert(refreshModelsAction, 'handleRefreshProviderModels did not trigger refresh_models action');
console.log('10. A50: abandoned Antigravity -> NVIDIA/OpenRouter does not retain its slot');
for (const provider of ['nvidia', 'openrouter']) {
sandbox.openAddAccountWizard();
sandbox.showWizardStep2('antigravity');
getOrCreateElement('wiz-redirect-slot').value = 'ag-w1';
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.finishAddAccount(provider);
const action = [...executedActions].reverse().find(a => a.action === 'add_account');
assert.strictEqual(action.data.provider, provider);
assert.strictEqual(action.data.profile_id, '');
}
console.log('\nAll targeted Node.js DOM and contract test assertions PASSED successfully!');
process.exit(0);
}