Fix account slot isolation and add background checks with per-account model discovery
This commit is contained in:
parent
1fbbeacd13
commit
79ac9cf561
24 changed files with 901 additions and 72 deletions
183
agents/inbox/2026-08-31-A50-accounts-discovery.md
Normal file
183
agents/inbox/2026-08-31-A50-accounts-discovery.md
Normal 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
66
docs/reports/A50.md
Normal 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 не менялся.
|
||||||
BIN
docs/screenshots/a50/accounts-checked.png
Normal file
BIN
docs/screenshots/a50/accounts-checked.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/screenshots/a50/accounts-checking.png
Normal file
BIN
docs/screenshots/a50/accounts-checking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/screenshots/a50/accounts-nvidia-dark.png
Normal file
BIN
docs/screenshots/a50/accounts-nvidia-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/screenshots/a50/accounts-nvidia-light.png
Normal file
BIN
docs/screenshots/a50/accounts-nvidia-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/screenshots/a50/accounts-nvidia-medium.png
Normal file
BIN
docs/screenshots/a50/accounts-nvidia-medium.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/screenshots/a50/openrouter-wizard-result.png
Normal file
BIN
docs/screenshots/a50/openrouter-wizard-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
94
src/antigravity_provider/router/account_probe_service.py
Normal file
94
src/antigravity_provider/router/account_probe_service.py
Normal 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
|
||||||
|
|
@ -35,7 +35,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||||||
)
|
)
|
||||||
return ok, msg
|
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()
|
config = load_router_config()
|
||||||
pcfg = config.get_profile(profile_id)
|
pcfg = config.get_profile(profile_id)
|
||||||
if not pcfg:
|
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':
|
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
||||||
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
||||||
|
|
||||||
model = pcfg.preferred_models[0] if pcfg.preferred_models else 'default'
|
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
||||||
|
|
@ -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 = threading.Thread(target=_call_invoke, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
t.join(timeout=10.0)
|
t.join(timeout=timeout)
|
||||||
|
|
||||||
el = round(time.time() - t0, 2)
|
el = round(time.time() - t0, 2)
|
||||||
|
|
||||||
|
|
@ -86,7 +89,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'duration_sec': el,
|
'duration_sec': el,
|
||||||
'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)',
|
'error': f'Превышено время ожидания ответа от провайдера ({timeout:g}с). Повторите проверку.',
|
||||||
}
|
}
|
||||||
|
|
||||||
if error_container:
|
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_discovery_service import ModelDiscoveryService
|
||||||
from antigravity_provider.router.model_registry import ModelRegistry
|
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:
|
if discovered is None:
|
||||||
try:
|
try:
|
||||||
discovered = ModelDiscoveryService.get().discover_models_sync(provider, timeout=5.0)
|
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
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
from .account_probe_service import AccountProbeService
|
||||||
|
AccountProbeService.get().schedule_all()
|
||||||
except Exception as exc: # пересбор не должен ронять сам вход
|
except Exception as exc: # пересбор не должен ронять сам вход
|
||||||
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
|
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
|
||||||
|
|
||||||
|
|
@ -533,8 +538,30 @@ def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]:
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
"""Shared execution layer for Desktop and Web actions."""
|
"""Shared execution layer for Desktop and Web actions."""
|
||||||
|
|
||||||
|
_connect_lock = threading.Lock()
|
||||||
|
_pending_connections: Dict[str, str] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
|
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.
|
Execute the specified action.
|
||||||
If async_runner is provided, long actions will be dispatched to it.
|
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)
|
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
|
||||||
if not slot:
|
if not slot:
|
||||||
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, slot)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason}
|
||||||
try:
|
try:
|
||||||
if provider == 'grok':
|
if provider == 'grok':
|
||||||
from antigravity_provider.router.grok_oauth import start_grok_oauth
|
from antigravity_provider.router.grok_oauth import start_grok_oauth
|
||||||
|
|
@ -697,7 +727,9 @@ class ActionExecutor:
|
||||||
else:
|
else:
|
||||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
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)
|
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
|
||||||
if not ok:
|
if not ok:
|
||||||
return {'ok': False, 'message': def_msg}
|
return {'ok': False, 'message': def_msg}
|
||||||
|
|
@ -716,7 +748,10 @@ class ActionExecutor:
|
||||||
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
||||||
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||||
_rescan_after_auth()
|
_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:
|
except Exception as e:
|
||||||
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {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)
|
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
|
||||||
if not slot:
|
if not slot:
|
||||||
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, slot)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason}
|
||||||
try:
|
try:
|
||||||
if provider == 'antigravity':
|
if provider == 'antigravity':
|
||||||
from antigravity_provider.router.profile_oauth import (
|
from antigravity_provider.router.profile_oauth import (
|
||||||
|
|
@ -857,8 +895,7 @@ class ActionExecutor:
|
||||||
|
|
||||||
elif action == 'test':
|
elif action == 'test':
|
||||||
if async_runner:
|
if async_runner:
|
||||||
async_runner(lambda: do_test_profile(prov, pid), 'TestProfile')
|
return cls._execute('check_account', {'provider': prov, 'profile_id': pid}, async_runner, actor)
|
||||||
return {'ok': True, 'message': 'запущено'}
|
|
||||||
else:
|
else:
|
||||||
res = do_test_profile(prov, pid)
|
res = do_test_profile(prov, pid)
|
||||||
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
||||||
|
|
@ -931,6 +968,23 @@ class ActionExecutor:
|
||||||
elif action == 'refresh_data':
|
elif action == 'refresh_data':
|
||||||
return {'ok': True, 'message': 'Обновление данных'}
|
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':
|
elif action == 'refresh_all':
|
||||||
if async_runner:
|
if async_runner:
|
||||||
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')
|
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ DEFAULT_SLOT_ROLES = {
|
||||||
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
||||||
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
||||||
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
||||||
"local-1": ("Локальный сервер 1", "coder", "primary"),
|
"local-1": ("llama.cpp 1", "coder", "primary"),
|
||||||
"local-2": ("Локальный сервер 2", "fast", "primary"),
|
"local-2": ("llama.cpp 2", "fast", "primary"),
|
||||||
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
|
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
|
||||||
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
|
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
|
||||||
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
||||||
|
|
@ -80,7 +80,7 @@ class AutoAssigner:
|
||||||
"opengo": "OpenCode",
|
"opengo": "OpenCode",
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"grok": "Grok",
|
"grok": "Grok",
|
||||||
"local": "Локальный сервер",
|
"local": "llama.cpp",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"nvidia": "NVIDIA NIM",
|
"nvidia": "NVIDIA NIM",
|
||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
|
|
@ -181,6 +181,34 @@ class AutoAssigner:
|
||||||
|
|
||||||
return None
|
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
|
@staticmethod
|
||||||
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
|
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||||
"""Persist a router profile for provider slots introduced by the UI.
|
"""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
|
If discovery has not run yet, preferred_models remains empty [] instead
|
||||||
of inventing unsupported model literals.
|
of inventing unsupported model literals.
|
||||||
"""
|
"""
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, profile_id)
|
||||||
|
if not valid:
|
||||||
|
return False, reason
|
||||||
config = load_router_config()
|
config = load_router_config()
|
||||||
if profile_id in config.profiles:
|
if profile_id in config.profiles:
|
||||||
return True, "Профиль уже зарегистрирован"
|
return True, "Профиль уже зарегистрирован"
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class ModelDiscoveryService:
|
||||||
self._cache_path = cache_path
|
self._cache_path = cache_path
|
||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._probe_context = threading.local()
|
||||||
self._ttl_seconds: int = 3600 # 1 hour
|
self._ttl_seconds: int = 3600 # 1 hour
|
||||||
self._load_cache_from_disk()
|
self._load_cache_from_disk()
|
||||||
|
|
||||||
|
|
@ -82,10 +83,11 @@ class ModelDiscoveryService:
|
||||||
return None
|
return None
|
||||||
return list(meta["models"])
|
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."""
|
"""Return cached models and freshness status without blocking."""
|
||||||
with self._cache_lock:
|
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:
|
if not entry or "models" not in entry:
|
||||||
return {
|
return {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
|
@ -101,10 +103,10 @@ class ModelDiscoveryService:
|
||||||
models = entry.get("models")
|
models = entry.get("models")
|
||||||
return {
|
return {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"models": list(models) if models else None,
|
"models": list(models) if models is not None else None,
|
||||||
"discovered_at": discovered_at,
|
"discovered_at": discovered_at,
|
||||||
"is_stale": is_stale,
|
"is_stale": is_stale,
|
||||||
"has_cache": bool(models),
|
"has_cache": models is not None,
|
||||||
"error": entry.get("error"),
|
"error": entry.get("error"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,13 +190,15 @@ class ModelDiscoveryService:
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
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."""
|
"""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]
|
result_holder: List[Optional[List[str]]] = [None]
|
||||||
error_holder: List[Optional[str]] = [None]
|
error_holder: List[Optional[str]] = [None]
|
||||||
|
|
||||||
def _do_probe():
|
def _do_probe():
|
||||||
try:
|
try:
|
||||||
|
self._probe_context.profile_id = profile_id
|
||||||
models, err_msg = self._probe_provider(provider)
|
models, err_msg = self._probe_provider(provider)
|
||||||
result_holder[0] = models
|
result_holder[0] = models
|
||||||
error_holder[0] = err_msg
|
error_holder[0] = err_msg
|
||||||
|
|
@ -209,9 +213,9 @@ class ModelDiscoveryService:
|
||||||
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
|
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
|
||||||
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
|
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower(), {})
|
entry = self._cache.get(cache_key, {})
|
||||||
existing_models = entry.get("models")
|
existing_models = entry.get("models")
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": existing_models,
|
"models": existing_models,
|
||||||
"discovered_at": entry.get("discovered_at"),
|
"discovered_at": entry.get("discovered_at"),
|
||||||
"error": timeout_msg,
|
"error": timeout_msg,
|
||||||
|
|
@ -222,9 +226,9 @@ class ModelDiscoveryService:
|
||||||
models = result_holder[0]
|
models = result_holder[0]
|
||||||
err_text = error_holder[0]
|
err_text = error_holder[0]
|
||||||
|
|
||||||
if models:
|
if models is not None and not err_text:
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": models,
|
"models": models,
|
||||||
"discovered_at": time.time(),
|
"discovered_at": time.time(),
|
||||||
"error": None,
|
"error": None,
|
||||||
|
|
@ -236,9 +240,9 @@ class ModelDiscoveryService:
|
||||||
if err_text:
|
if err_text:
|
||||||
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
|
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower(), {})
|
entry = self._cache.get(cache_key, {})
|
||||||
existing_models = entry.get("models")
|
existing_models = entry.get("models")
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": existing_models,
|
"models": existing_models,
|
||||||
"discovered_at": entry.get("discovered_at"),
|
"discovered_at": entry.get("discovered_at"),
|
||||||
"error": err_text,
|
"error": err_text,
|
||||||
|
|
@ -247,9 +251,9 @@ class ModelDiscoveryService:
|
||||||
return list(existing_models) if existing_models else None
|
return list(existing_models) if existing_models else None
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower(), {})
|
entry = self._cache.get(cache_key, {})
|
||||||
existing_models = entry.get("models")
|
existing_models = entry.get("models")
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": existing_models,
|
"models": existing_models,
|
||||||
"discovered_at": entry.get("discovered_at"),
|
"discovered_at": entry.get("discovered_at"),
|
||||||
"error": entry.get("error") or "Модели не найдены",
|
"error": entry.get("error") or "Модели не найдены",
|
||||||
|
|
@ -257,9 +261,37 @@ class ModelDiscoveryService:
|
||||||
self._save_cache_to_disk()
|
self._save_cache_to_disk()
|
||||||
return list(existing_models) if existing_models else None
|
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:
|
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)
|
err_json = json.loads(raw_err)
|
||||||
if isinstance(err_json, dict):
|
if isinstance(err_json, dict):
|
||||||
if "error" in err_json:
|
if "error" in err_json:
|
||||||
|
|
@ -278,12 +310,16 @@ class ModelDiscoveryService:
|
||||||
msg = raw_err
|
msg = raw_err
|
||||||
return f"HTTP {http_err.code}: {msg}"
|
return f"HTTP {http_err.code}: {msg}"
|
||||||
except Exception:
|
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]]]:
|
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
|
||||||
from antigravity_provider.router.router_config import load_router_config
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
cfg = load_router_config()
|
cfg = load_router_config()
|
||||||
p_lower = prov.lower()
|
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 = [
|
matched = [
|
||||||
(pid, pcfg)
|
(pid, pcfg)
|
||||||
for pid, pcfg in cfg.profiles.items()
|
for pid, pcfg in cfg.profiles.items()
|
||||||
|
|
@ -326,7 +362,7 @@ class ModelDiscoveryService:
|
||||||
|
|
||||||
if prov in ("antigravity", "google-antigravity"):
|
if prov in ("antigravity", "google-antigravity"):
|
||||||
from antigravity_provider.agy_subprocess import discover_models
|
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:
|
try:
|
||||||
res = discover_models(profile_id=main_p)
|
res = discover_models(profile_id=main_p)
|
||||||
if res:
|
if res:
|
||||||
|
|
@ -614,8 +650,7 @@ class ModelDiscoveryService:
|
||||||
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
|
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
|
||||||
if name:
|
if name:
|
||||||
models.append(str(name))
|
models.append(str(name))
|
||||||
if models:
|
return sorted(set(models)), None
|
||||||
return sorted(set(models)), None
|
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_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)
|
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
|
||||||
if mid:
|
if mid:
|
||||||
models.append(str(mid))
|
models.append(str(mid))
|
||||||
if models:
|
return sorted(set(models)), None
|
||||||
return sorted(set(models)), None
|
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
|
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"auto_update": True,
|
"auto_update": True,
|
||||||
"release_channel": "stable",
|
"release_channel": "stable",
|
||||||
"model_timeout_seconds": 60,
|
"model_timeout_seconds": 60,
|
||||||
|
"account_check_interval_seconds": 300,
|
||||||
"monitoring_interval_seconds": 30,
|
"monitoring_interval_seconds": 30,
|
||||||
"quota_threshold_percent": 10.0,
|
"quota_threshold_percent": 10.0,
|
||||||
"quota_threshold_action": "notify",
|
"quota_threshold_action": "notify",
|
||||||
|
|
@ -75,6 +76,11 @@ def get_hub_settings() -> Dict[str, Any]:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Normalize numeric types
|
||||||
try:
|
try:
|
||||||
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))
|
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ class ProfileViewModel:
|
||||||
plan_source: str = "unknown"
|
plan_source: str = "unknown"
|
||||||
quota_snapshot: Optional[Any] = None
|
quota_snapshot: Optional[Any] = None
|
||||||
preferred_models: List[str] = field(default_factory=list)
|
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
|
active_leases: int = 0
|
||||||
request_options: dict[str, Any] = field(default_factory=dict)
|
request_options: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
@ -518,15 +520,30 @@ class UnifiedHealthService:
|
||||||
"nvidia": "NVIDIA NIM",
|
"nvidia": "NVIDIA NIM",
|
||||||
"nvidia-nim": "NVIDIA NIM",
|
"nvidia-nim": "NVIDIA NIM",
|
||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"local": "Local LLM",
|
"local": "llama.cpp",
|
||||||
"local-llm": "Local LLM",
|
"local-llm": "llama.cpp",
|
||||||
"llama.cpp": "Local LLM (llama.cpp)",
|
"llama.cpp": "llama.cpp",
|
||||||
"vllm": "vLLM",
|
"vllm": "vLLM",
|
||||||
}.get(prov.lower(), prov)
|
}.get(prov.lower(), prov)
|
||||||
|
|
||||||
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
|
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(
|
vm = ProfileViewModel(
|
||||||
|
connection_check=check,
|
||||||
|
model_discovery=model_meta,
|
||||||
profile_id=pid,
|
profile_id=pid,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
account_identity=ident.primary_identifier() if is_authenticated else identity,
|
account_identity=ident.primary_identifier() if is_authenticated else identity,
|
||||||
|
|
@ -541,7 +558,7 @@ class UnifiedHealthService:
|
||||||
health_label_ru=health_lbl,
|
health_label_ru=health_lbl,
|
||||||
model_states=model_states,
|
model_states=model_states,
|
||||||
cooldown_remaining_sec=max_cd,
|
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,
|
last_success_at=last_success_str,
|
||||||
enabled=pcfg.enabled,
|
enabled=pcfg.enabled,
|
||||||
is_cold_spare=is_cold,
|
is_cold_spare=is_cold,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.encoders import jsonable_encoder
|
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.state_store import HubStateStore
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
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()
|
threading.Thread(target=func, name=name, daemon=True).start()
|
||||||
|
|
||||||
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
||||||
result = 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"):
|
if result.get("unknown"):
|
||||||
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
||||||
|
|
||||||
|
|
@ -412,6 +413,9 @@ _SNAPSHOT_REFRESH_SEC = 30
|
||||||
|
|
||||||
def _background_refresh_loop() -> None:
|
def _background_refresh_loop() -> None:
|
||||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
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:
|
try:
|
||||||
AccountQuotaService.get().fetch_all_configured(force=True)
|
AccountQuotaService.get().fetch_all_configured(force=True)
|
||||||
|
|
@ -428,6 +432,7 @@ def _background_refresh_loop() -> None:
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
AccountProbeService.get().tick()
|
||||||
HubStateStore.get().refresh(force_scan=False)
|
HubStateStore.get().refresh(force_scan=False)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Snapshot refresh failed: %s", exc)
|
logger.warning("Snapshot refresh failed: %s", exc)
|
||||||
|
|
|
||||||
|
|
@ -637,9 +637,11 @@ function renderAccountsView() {
|
||||||
'opencode-go': 'OpenCode Go',
|
'opencode-go': 'OpenCode Go',
|
||||||
claude: 'Claude (Anthropic)',
|
claude: 'Claude (Anthropic)',
|
||||||
grok: 'Grok (xAI)',
|
grok: 'Grok (xAI)',
|
||||||
local: 'Local LLM',
|
nvidia: 'NVIDIA NIM',
|
||||||
'local-llm': 'Local LLM',
|
openrouter: 'OpenRouter',
|
||||||
'llama.cpp': 'Local LLM (llama.cpp)',
|
local: 'llama.cpp',
|
||||||
|
'local-llm': 'llama.cpp',
|
||||||
|
'llama.cpp': 'llama.cpp',
|
||||||
ollama: 'Ollama',
|
ollama: 'Ollama',
|
||||||
vllm: 'vLLM',
|
vllm: 'vLLM',
|
||||||
};
|
};
|
||||||
|
|
@ -647,7 +649,7 @@ function renderAccountsView() {
|
||||||
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
||||||
let totalProfiles = 0;
|
let totalProfiles = 0;
|
||||||
let visibleProfiles = 0;
|
let visibleProfiles = 0;
|
||||||
let html = '';
|
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)) {
|
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
|
||||||
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
||||||
|
|
@ -695,7 +697,7 @@ function renderAccountsView() {
|
||||||
<div class="provider-group">
|
<div class="provider-group">
|
||||||
<div class="provider-group-header">
|
<div class="provider-group-header">
|
||||||
<div class="provider-group-title">
|
<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>
|
<span>${providerNames[providerId] || providerId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
|
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
|
||||||
|
|
@ -788,11 +790,33 @@ function renderAccountCard(profile) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
|
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
|
||||||
|
${renderAccountCheck(profile)}
|
||||||
${quotaGridHtml}
|
${quotaGridHtml}
|
||||||
</div>
|
</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) {
|
function renderQuotaCell(bucket, unavailableReason) {
|
||||||
const remaining = bucket.remaining_percent;
|
const remaining = bucket.remaining_percent;
|
||||||
let formattedValue = 'Н/Д';
|
let formattedValue = 'Н/Д';
|
||||||
|
|
@ -1474,6 +1498,7 @@ function renderSettingsView() {
|
||||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||||
|
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||||
|
|
||||||
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
||||||
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
||||||
|
|
@ -1484,6 +1509,17 @@ function renderSettingsView() {
|
||||||
if (emailMaskingSel && s.email_masking_mode) {
|
if (emailMaskingSel && s.email_masking_mode) {
|
||||||
emailMaskingSel.value = 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) {
|
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
|
||||||
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
||||||
}
|
}
|
||||||
|
|
@ -1494,8 +1530,10 @@ async function saveHubServerSettings() {
|
||||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||||
|
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||||
|
|
||||||
const newSettings = {};
|
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 (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
|
||||||
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
|
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
|
||||||
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
||||||
|
|
@ -1673,7 +1711,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
||||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
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 currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||||
const qs = profile.quota_snapshot;
|
const qs = profile.quota_snapshot;
|
||||||
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
||||||
|
|
@ -1695,13 +1733,15 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
|
||||||
modelBlockHtml = `
|
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="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;">
|
<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>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
|
||||||
|
|
||||||
// Local request options section
|
// Local request options section
|
||||||
let requestOptionsHtml = '';
|
let requestOptionsHtml = '';
|
||||||
if (profile.provider === 'local') {
|
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.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div id="modal-feedback-area"></div>
|
<div id="modal-feedback-area"></div>
|
||||||
|
|
@ -2267,7 +2308,7 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
|
||||||
|
|
||||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||||
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
||||||
const res = await executeAction('refresh_models', { provider: providerId });
|
const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' });
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
showToast('Запрос обновления моделей отправлен', 'success');
|
showToast('Запрос обновления моделей отправлен', 'success');
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
|
|
@ -2288,7 +2329,7 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
||||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
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 currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||||
const qs = profile.quota_snapshot;
|
const qs = profile.quota_snapshot;
|
||||||
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
||||||
|
|
@ -2310,13 +2351,14 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
|
||||||
modelBlockHtml = `
|
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="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;">
|
<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>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
|
||||||
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div id="modal-feedback-area"></div>
|
<div id="modal-feedback-area"></div>
|
||||||
|
|
@ -2400,7 +2442,7 @@ function openAgentModelModal(roleId, profileId) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
||||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
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 currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||||
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
|
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="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;">
|
<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>
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')">↻ Запросить список моделей</button>
|
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')">↻ Запросить список моделей</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2475,10 +2517,10 @@ async function handleTestProfile(profileId) {
|
||||||
if (feedbackArea) {
|
if (feedbackArea) {
|
||||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
|
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 (feedbackArea) {
|
||||||
if (res && res.ok) {
|
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 {
|
} else {
|
||||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
|
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -2500,6 +2542,10 @@ function openAddAccountWizard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWizardStep1() {
|
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 = 'Мастер подключения учетной записи';
|
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||||
|
|
@ -2577,6 +2623,12 @@ function showWizardStep1() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWizardStep2(providerId) {
|
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 bodyHtml = '';
|
||||||
let footerHtml = '';
|
let footerHtml = '';
|
||||||
|
|
||||||
|
|
@ -2752,14 +2804,10 @@ function proceedToWizardStep3(providerId) {
|
||||||
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
|
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
|
||||||
if (isDeviceAuthFlow) {
|
if (isDeviceAuthFlow) {
|
||||||
const deviceSlot = document.getElementById('wiz-device-slot');
|
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) {
|
} else if (isRedirectAuthFlow) {
|
||||||
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
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
|
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
|
||||||
showWizardStep3(providerId);
|
showWizardStep3(providerId);
|
||||||
|
|
@ -2811,6 +2859,10 @@ function showWizardStep3(providerId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finishAddAccount(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 roleSelect = document.getElementById('wiz-target-role');
|
||||||
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
|
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
|
||||||
|
|
||||||
|
|
@ -2831,7 +2883,7 @@ async function finishAddAccount(providerId) {
|
||||||
|
|
||||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||||
if (feedbackArea) {
|
if (feedbackArea) {
|
||||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
|
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохранение аккаунта и запуск проверки. Опрос провайдера может занять до минуты на этап.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -2847,10 +2899,12 @@ async function finishAddAccount(providerId) {
|
||||||
payload.token = window._wiz_token;
|
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) {
|
if (res && res.ok) {
|
||||||
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
|
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2888,6 +2942,7 @@ async function startDeviceAuth(providerId) {
|
||||||
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
|
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
|
||||||
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
|
// 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 });
|
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
|
||||||
|
if (window._wiz_provider !== providerId) return;
|
||||||
if (!res || !res.ok) {
|
if (!res || !res.ok) {
|
||||||
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
@ -2967,7 +3022,7 @@ function profilesInRouting() {
|
||||||
function buildSlotOptions(providerId) {
|
function buildSlotOptions(providerId) {
|
||||||
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
|
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
|
||||||
if (!profiles.length) {
|
if (!profiles.length) {
|
||||||
return '<option value="">Список слотов ещё не получен</option>';
|
return '<option value="">Новый свободный слот — автоматически</option>';
|
||||||
}
|
}
|
||||||
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
|
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
|
||||||
// ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый
|
// ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый
|
||||||
|
|
@ -2992,7 +3047,7 @@ function buildSlotOptions(providerId) {
|
||||||
else used.push(opt);
|
else used.push(opt);
|
||||||
});
|
});
|
||||||
// Свободные слоты с ролью — первыми: именно они дают работающий маршрут.
|
// Свободные слоты с ролью — первыми: именно они дают работающий маршрут.
|
||||||
return free.concat(used, idle).join('');
|
return '<option value="">Новый свободный слот — автоматически</option>' + free.concat(used, idle).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
let _redirectAuthTimer = null;
|
let _redirectAuthTimer = null;
|
||||||
|
|
@ -3030,6 +3085,7 @@ async function startRedirectAuth(providerId) {
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
profile_id: chosen || undefined,
|
profile_id: chosen || undefined,
|
||||||
});
|
});
|
||||||
|
if (window._wiz_provider !== providerId) return;
|
||||||
if (!res || !res.ok) {
|
if (!res || !res.ok) {
|
||||||
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,13 @@
|
||||||
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
|
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
|
||||||
</div>
|
</div>
|
||||||
</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-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<div class="setting-label">Интервал обновления квот</div>
|
<div class="setting-label">Интервал обновления квот</div>
|
||||||
|
|
|
||||||
|
|
@ -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 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; }
|
.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; }
|
.accounts-groups-container { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||||
.provider-group,.accounts-grid { display:contents; }
|
.provider-group { grid-column:1 / -1; min-width:0; }
|
||||||
.provider-group-header { display:none; }
|
.provider-group-header { display:flex; justify-content:space-between; align-items:center; margin:12px 0; }
|
||||||
.account-card { min-width:0; padding:16px; }
|
.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-card-header { gap:8px; }
|
||||||
.account-provider-tag { display:flex; align-items:center; gap:10px; }
|
.account-provider-tag { display:flex; align-items:center; gap:10px; }
|
||||||
.account-provider-tag img { width:30px; height:30px; object-fit:contain; }
|
.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 { display:flex; align-items:center; gap:6px; min-width:0; }
|
||||||
.route-model-with-logo select { min-width:0; }
|
.route-model-with-logo select { min-width:0; }
|
||||||
.agent-node-icon .brand-logo { width:27px; height:27px; }
|
.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); }
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ function arrangeSettingsPanels() {
|
||||||
const first = view.querySelector('.settings-card');
|
const first = view.querySelector('.settings-card');
|
||||||
const groups = [
|
const groups = [
|
||||||
['Общие настройки',['setting-theme']],
|
['Общие настройки',['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']],
|
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
|
||||||
];
|
];
|
||||||
for (const [title,ids] of groups) {
|
for (const [title,ids] of groups) {
|
||||||
|
|
|
||||||
50
tests/manual/a50_preview.py
Normal file
50
tests/manual/a50_preview.py
Normal 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)
|
||||||
|
|
@ -111,7 +111,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
assert "успешно подключен" in res["message"]
|
assert "сохранён" in res["message"]
|
||||||
|
|
||||||
# Verify profile created and auth saved
|
# Verify profile created and auth saved
|
||||||
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
||||||
|
|
@ -137,7 +137,7 @@ def test_p0_1_add_account_nvidia_default_base_url():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
assert "успешно подключен" in res["message"]
|
assert "сохранён" in res["message"]
|
||||||
|
|
||||||
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
||||||
assert auth is not None
|
assert auth is not None
|
||||||
|
|
|
||||||
199
tests/test_accounts_a50.py
Normal file
199
tests/test_accounts_a50.py
Normal 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"]
|
||||||
|
|
@ -32,7 +32,8 @@ def test_check_memory_freshness_real_repo():
|
||||||
)
|
)
|
||||||
assert is_fresh is True
|
assert is_fresh is True
|
||||||
assert "FRESH" in summary
|
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):
|
def test_check_memory_freshness_missing_file_strict_vs_non_strict(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,24 @@ async function runTests() {
|
||||||
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
|
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
|
||||||
assert(refreshModelsAction, 'handleRefreshProviderModels did not trigger refresh_models action');
|
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!');
|
console.log('\nAll targeted Node.js DOM and contract test assertions PASSED successfully!');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue