diff --git a/agents/inbox/2026-08-31-A50-accounts-discovery.md b/agents/inbox/2026-08-31-A50-accounts-discovery.md new file mode 100644 index 0000000..2241abf --- /dev/null +++ b/agents/inbox/2026-08-31-A50-accounts-discovery.md @@ -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`. \ No newline at end of file diff --git a/docs/reports/A50.md b/docs/reports/A50.md new file mode 100644 index 0000000..ad8202b --- /dev/null +++ b/docs/reports/A50.md @@ -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 не менялся. diff --git a/docs/screenshots/a50/accounts-checked.png b/docs/screenshots/a50/accounts-checked.png new file mode 100644 index 0000000..e721b1b Binary files /dev/null and b/docs/screenshots/a50/accounts-checked.png differ diff --git a/docs/screenshots/a50/accounts-checking.png b/docs/screenshots/a50/accounts-checking.png new file mode 100644 index 0000000..fe1f749 Binary files /dev/null and b/docs/screenshots/a50/accounts-checking.png differ diff --git a/docs/screenshots/a50/accounts-nvidia-dark.png b/docs/screenshots/a50/accounts-nvidia-dark.png new file mode 100644 index 0000000..de89416 Binary files /dev/null and b/docs/screenshots/a50/accounts-nvidia-dark.png differ diff --git a/docs/screenshots/a50/accounts-nvidia-light.png b/docs/screenshots/a50/accounts-nvidia-light.png new file mode 100644 index 0000000..9dee1b9 Binary files /dev/null and b/docs/screenshots/a50/accounts-nvidia-light.png differ diff --git a/docs/screenshots/a50/accounts-nvidia-medium.png b/docs/screenshots/a50/accounts-nvidia-medium.png new file mode 100644 index 0000000..7a22896 Binary files /dev/null and b/docs/screenshots/a50/accounts-nvidia-medium.png differ diff --git a/docs/screenshots/a50/openrouter-wizard-result.png b/docs/screenshots/a50/openrouter-wizard-result.png new file mode 100644 index 0000000..85fe20f Binary files /dev/null and b/docs/screenshots/a50/openrouter-wizard-result.png differ diff --git a/src/antigravity_provider/router/account_probe_service.py b/src/antigravity_provider/router/account_probe_service.py new file mode 100644 index 0000000..54e292b --- /dev/null +++ b/src/antigravity_provider/router/account_probe_service.py @@ -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 diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 37faa22..83b2f29 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -35,7 +35,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]: ) return ok, msg -def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: +def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, discovered_models: Optional[List[str]] = None) -> Dict[str, Any]: + valid, reason = AutoAssigner.validate_slot(provider, profile_id) + if not valid: + return {"success": False, "error": reason} config = load_router_config() pcfg = config.get_profile(profile_id) if not pcfg: @@ -52,7 +55,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED': return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'} - model = pcfg.preferred_models[0] if pcfg.preferred_models else 'default' + model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0] t0 = time.time() try: auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id) @@ -78,7 +81,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: t = threading.Thread(target=_call_invoke, daemon=True) t.start() - t.join(timeout=10.0) + t.join(timeout=timeout) el = round(time.time() - t0, 2) @@ -86,7 +89,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: return { 'success': False, 'duration_sec': el, - 'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)', + 'error': f'Превышено время ожидания ответа от провайдера ({timeout:g}с). Повторите проверку.', } if error_container: @@ -239,7 +242,7 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) -> from antigravity_provider.router.model_discovery_service import ModelDiscoveryService from antigravity_provider.router.model_registry import ModelRegistry - discovered = ModelDiscoveryService.get().get_models(provider) + discovered = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id).get("models") or ModelDiscoveryService.get().get_models(provider) if discovered is None: try: discovered = ModelDiscoveryService.get().discover_models_sync(provider, timeout=5.0) @@ -337,6 +340,8 @@ def _rescan_after_auth() -> None: from antigravity_provider.router.state_store import HubStateStore HubStateStore.get().refresh(force_scan=True) + from .account_probe_service import AccountProbeService + AccountProbeService.get().schedule_all() except Exception as exc: # пересбор не должен ронять сам вход logger.warning("Не удалось пересобрать снапшот после входа: %s", exc) @@ -533,8 +538,30 @@ def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]: class ActionExecutor: """Shared execution layer for Desktop and Web actions.""" + _connect_lock = threading.Lock() + _pending_connections: Dict[str, str] = {} + @classmethod def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]: + if action != "add_account": + return cls._execute(action, data, async_runner, actor) + import hashlib + from .account_probe_service import AccountProbeService + fingerprint = hashlib.sha256(json.dumps([ + data.get("provider"), data.get("token") or data.get("api_key"), data.get("base_url") + ]).encode()).hexdigest() + with cls._connect_lock: + cls._pending_connections = {key: pid for key, pid in cls._pending_connections.items() if AccountProbeService.get().state(pid).get("state") == "checking"} + previous = cls._pending_connections.get(fingerprint) + if previous and AccountProbeService.get().state(previous).get("state") == "checking": + return {"ok": False, "message": f"Аккаунт {previous} уже сохранён и проверяется. Дождитесь результата."} + result = cls._execute(action, data, async_runner, actor) + if result.get("ok"): + cls._pending_connections[fingerprint] = result["data"]["profile_id"] + return result + + @classmethod + def _execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]: """ Execute the specified action. If async_runner is provided, long actions will be dispatched to it. @@ -602,6 +629,9 @@ class ActionExecutor: slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider) if not slot: return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'} + valid, reason = AutoAssigner.validate_slot(provider, slot) + if not valid: + return {'ok': False, 'message': reason} try: if provider == 'grok': from antigravity_provider.router.grok_oauth import start_grok_oauth @@ -697,7 +727,9 @@ class ActionExecutor: else: return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'} - slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1' + slot = slot or AutoAssigner.find_free_slot(prov_norm) + if not slot: + return {'ok': False, 'message': 'Нет свободного слота'} ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot) if not ok: return {'ok': False, 'message': def_msg} @@ -716,7 +748,10 @@ class ActionExecutor: ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data) AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False) _rescan_after_auth() - return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'} + from antigravity_provider.router.account_probe_service import AccountProbeService + AccountProbeService.get().schedule(prov_norm, slot, force=True) + check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена' + return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}} except Exception as e: return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'} @@ -740,6 +775,9 @@ class ActionExecutor: slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider) if not slot: return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'} + valid, reason = AutoAssigner.validate_slot(provider, slot) + if not valid: + return {'ok': False, 'message': reason} try: if provider == 'antigravity': from antigravity_provider.router.profile_oauth import ( @@ -857,8 +895,7 @@ class ActionExecutor: elif action == 'test': if async_runner: - async_runner(lambda: do_test_profile(prov, pid), 'TestProfile') - return {'ok': True, 'message': 'запущено'} + return cls._execute('check_account', {'provider': prov, 'profile_id': pid}, async_runner, actor) else: res = do_test_profile(prov, pid) return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res} @@ -931,6 +968,23 @@ class ActionExecutor: elif action == 'refresh_data': return {'ok': True, 'message': 'Обновление данных'} + elif action == 'check_account': + from antigravity_provider.router.account_probe_service import AccountProbeService + if not AccountProbeService.get().enabled: + return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."} + valid, reason = AutoAssigner.validate_slot(prov, pid) + if not valid: + return {'ok': False, 'message': reason} + started = AccountProbeService.get().schedule(prov, pid, force=True) + return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'} + + elif action == 'check_all_accounts': + from antigravity_provider.router.account_probe_service import AccountProbeService + if not AccountProbeService.get().enabled: + return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."} + count = AccountProbeService.get().schedule_all(force=True) + return {'ok': True, 'message': f'Запущена проверка {count} аккаунтов'} + elif action == 'refresh_all': if async_runner: async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll') diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 8d02a07..85fbc9d 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -47,8 +47,8 @@ DEFAULT_SLOT_ROLES = { "opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"), "opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"), "opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"), - "local-1": ("Локальный сервер 1", "coder", "primary"), - "local-2": ("Локальный сервер 2", "fast", "primary"), + "local-1": ("llama.cpp 1", "coder", "primary"), + "local-2": ("llama.cpp 2", "fast", "primary"), "openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"), "openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"), "nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"), @@ -80,7 +80,7 @@ class AutoAssigner: "opengo": "OpenCode", "claude": "Claude", "grok": "Grok", - "local": "Локальный сервер", + "local": "llama.cpp", "openrouter": "OpenRouter", "nvidia": "NVIDIA NIM", "ollama": "Ollama", @@ -181,6 +181,34 @@ class AutoAssigner: return None + @staticmethod + def validate_slot(provider: str, profile_id: str) -> Tuple[bool, str]: + """Reject foreign and unsafe slots before any auth/config mutation.""" + import re + aliases = { + "antigravity": "ag", "google-antigravity": "ag", "agy": "ag", + "openai-codex": "codex", "codex": "codex", "openai": "codex", + "opencode-go": "opengo", "opencode": "opengo", + "anthropic": "claude", "claude": "claude", "xai": "grok", "grok": "grok", + "local": "local", "local-llm": "local", "llama.cpp": "local", + "nvidia-nim": "nvidia", "nvidia": "nvidia", + "openrouter": "openrouter", "ollama": "ollama", "vllm": "vllm", + } + owner = aliases.get(provider.strip().lower()) + if not owner or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", profile_id): + return False, "Недопустимый провайдер или идентификатор слота" + reserved = profile_id.split("-", 1)[0] + if reserved in set(aliases.values()) and reserved != owner: + return False, f"Слот {profile_id} не принадлежит провайдеру {provider}" + existing = load_router_config().get_profile(profile_id) + if existing: + valid = aliases.get(existing.provider.lower()) == owner + else: + valid = profile_id.startswith(owner + "-") + if not valid: + return False, f"Слот {profile_id} не принадлежит провайдеру {provider}" + return True, "" + @staticmethod def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]: """Persist a router profile for provider slots introduced by the UI. @@ -189,6 +217,9 @@ class AutoAssigner: If discovery has not run yet, preferred_models remains empty [] instead of inventing unsupported model literals. """ + valid, reason = AutoAssigner.validate_slot(provider, profile_id) + if not valid: + return False, reason config = load_router_config() if profile_id in config.profiles: return True, "Профиль уже зарегистрирован" diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index 95b3a9b..b229c19 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -35,6 +35,7 @@ class ModelDiscoveryService: self._cache_path = cache_path self._cache_lock = threading.Lock() self._cache: Dict[str, Dict[str, Any]] = {} + self._probe_context = threading.local() self._ttl_seconds: int = 3600 # 1 hour self._load_cache_from_disk() @@ -82,10 +83,11 @@ class ModelDiscoveryService: return None return list(meta["models"]) - def get_models_with_metadata(self, provider: str) -> Dict[str, Any]: + def get_models_with_metadata(self, provider: str, profile_id: Optional[str] = None) -> Dict[str, Any]: """Return cached models and freshness status without blocking.""" with self._cache_lock: - entry = self._cache.get(provider.lower()) + key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower() + entry = self._cache.get(key) if not entry or "models" not in entry: return { "provider": provider, @@ -101,10 +103,10 @@ class ModelDiscoveryService: models = entry.get("models") return { "provider": provider, - "models": list(models) if models else None, + "models": list(models) if models is not None else None, "discovered_at": discovered_at, "is_stale": is_stale, - "has_cache": bool(models), + "has_cache": models is not None, "error": entry.get("error"), } @@ -188,13 +190,15 @@ class ModelDiscoveryService: threading.Thread(target=_worker, daemon=True).start() - def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]: + def discover_models_sync(self, provider: str, timeout: float = 15.0, profile_id: Optional[str] = None) -> Optional[List[str]]: """Synchronously probe models with strict timeout without blocking indefinite hangs.""" + cache_key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower() result_holder: List[Optional[List[str]]] = [None] error_holder: List[Optional[str]] = [None] def _do_probe(): try: + self._probe_context.profile_id = profile_id models, err_msg = self._probe_provider(provider) result_holder[0] = models error_holder[0] = err_msg @@ -209,9 +213,9 @@ class ModelDiscoveryService: logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout) timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)" with self._cache_lock: - entry = self._cache.get(provider.lower(), {}) + entry = self._cache.get(cache_key, {}) existing_models = entry.get("models") - self._cache[provider.lower()] = { + self._cache[cache_key] = { "models": existing_models, "discovered_at": entry.get("discovered_at"), "error": timeout_msg, @@ -222,9 +226,9 @@ class ModelDiscoveryService: models = result_holder[0] err_text = error_holder[0] - if models: + if models is not None and not err_text: with self._cache_lock: - self._cache[provider.lower()] = { + self._cache[cache_key] = { "models": models, "discovered_at": time.time(), "error": None, @@ -236,9 +240,9 @@ class ModelDiscoveryService: if err_text: logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text) with self._cache_lock: - entry = self._cache.get(provider.lower(), {}) + entry = self._cache.get(cache_key, {}) existing_models = entry.get("models") - self._cache[provider.lower()] = { + self._cache[cache_key] = { "models": existing_models, "discovered_at": entry.get("discovered_at"), "error": err_text, @@ -247,9 +251,9 @@ class ModelDiscoveryService: return list(existing_models) if existing_models else None with self._cache_lock: - entry = self._cache.get(provider.lower(), {}) + entry = self._cache.get(cache_key, {}) existing_models = entry.get("models") - self._cache[provider.lower()] = { + self._cache[cache_key] = { "models": existing_models, "discovered_at": entry.get("discovered_at"), "error": entry.get("error") or "Модели не найдены", @@ -257,9 +261,37 @@ class ModelDiscoveryService: self._save_cache_to_disk() return list(existing_models) if existing_models else None - def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str: + def discover_ollama_cloud(self) -> Dict[str, Any]: + """Public catalog documented at https://docs.ollama.com/cloud#listing-models. + + Catalog presence is not proof of an account's inference entitlement. + """ + key = "ollama-cloud-catalog" + error = None + models = None try: - raw_err = http_err.read().decode("utf-8", errors="replace") + req = urllib.request.Request("https://ollama.com/api/tags", headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode("utf-8")) + models = sorted({str(m.get("name") or m.get("model")) for m in data.get("models", []) if isinstance(m, dict) and (m.get("name") or m.get("model"))}) + except urllib.error.HTTPError as exc: + error = self._extract_http_error(exc) + except Exception as exc: + error = str(exc) + with self._cache_lock: + previous = self._cache.get(key, {}) + self._cache[key] = { + "models": models if models is not None else previous.get("models"), + "discovered_at": time.time() if models is not None else previous.get("discovered_at"), + "error": error, + } + self._save_cache_to_disk() + return self.get_models_with_metadata(key) + + def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str: + raw_err = "" + try: + raw_err = http_err.read().decode("utf-8", errors="replace")[:2000] err_json = json.loads(raw_err) if isinstance(err_json, dict): if "error" in err_json: @@ -278,12 +310,16 @@ class ModelDiscoveryService: msg = raw_err return f"HTTP {http_err.code}: {msg}" except Exception: - return f"HTTP {http_err.code}: {http_err.reason}" + return f"HTTP {http_err.code}: {raw_err or http_err.reason}" def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]: from antigravity_provider.router.router_config import load_router_config cfg = load_router_config() p_lower = prov.lower() + requested = getattr(self._probe_context, "profile_id", None) + if requested: + pcfg = cfg.get_profile(requested) + return [(requested, pcfg)] if pcfg else [] matched = [ (pid, pcfg) for pid, pcfg in cfg.profiles.items() @@ -326,7 +362,7 @@ class ModelDiscoveryService: if prov in ("antigravity", "google-antigravity"): from antigravity_provider.agy_subprocess import discover_models - main_p = ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback" + main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback" try: res = discover_models(profile_id=main_p) if res: @@ -614,8 +650,7 @@ class ModelDiscoveryService: name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m) if name: models.append(str(name)) - if models: - return sorted(set(models)), None + return sorted(set(models)), None except urllib.error.HTTPError as http_err: last_err = self._extract_http_error(http_err) logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err) @@ -635,8 +670,7 @@ class ModelDiscoveryService: mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m) if mid: models.append(str(mid)) - if models: - return sorted(set(models)), None + return sorted(set(models)), None except urllib.error.HTTPError as http_err: last_err = self._extract_http_error(http_err) logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err) diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index e7f1e41..b2c457b 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -19,6 +19,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "auto_update": True, "release_channel": "stable", "model_timeout_seconds": 60, + "account_check_interval_seconds": 300, "monitoring_interval_seconds": 30, "quota_threshold_percent": 10.0, "quota_threshold_action": "notify", @@ -75,6 +76,11 @@ def get_hub_settings() -> Dict[str, Any]: except Exception: pass + try: + merged["account_check_interval_seconds"] = max(60, int(merged.get("account_check_interval_seconds", 300))) + except (ValueError, TypeError): + merged["account_check_interval_seconds"] = 300 + # Normalize numeric types try: merged["failover_attempts"] = int(merged.get("failover_attempts", 3)) diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 60de5c6..26c5fdc 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -97,6 +97,8 @@ class ProfileViewModel: plan_source: str = "unknown" quota_snapshot: Optional[Any] = None preferred_models: List[str] = field(default_factory=list) + connection_check: Dict[str, Any] = field(default_factory=dict) + model_discovery: Dict[str, Any] = field(default_factory=dict) active_leases: int = 0 request_options: dict[str, Any] = field(default_factory=dict) @@ -518,15 +520,30 @@ class UnifiedHealthService: "nvidia": "NVIDIA NIM", "nvidia-nim": "NVIDIA NIM", "ollama": "Ollama", - "local": "Local LLM", - "local-llm": "Local LLM", - "llama.cpp": "Local LLM (llama.cpp)", + "local": "llama.cpp", + "local-llm": "llama.cpp", + "llama.cpp": "llama.cpp", "vllm": "vLLM", }.get(prov.lower(), prov) last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None + from .account_probe_service import AccountProbeService + from .model_discovery_service import ModelDiscoveryService + check = AccountProbeService.get().state(pid) + model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid) + if prov == "ollama": + model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog") + if is_authenticated and pcfg.enabled: + if check.get("state") == "checking": + health_state, health_lbl = "checking", "Проверяется…" + elif check.get("state") == "failed": + health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д") + elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY): + health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает" vm = ProfileViewModel( + connection_check=check, + model_discovery=model_meta, profile_id=pid, display_name=display_name, account_identity=ident.primary_identifier() if is_authenticated else identity, @@ -541,7 +558,7 @@ class UnifiedHealthService: health_label_ru=health_lbl, model_states=model_states, cooldown_remaining_sec=max_cd, - last_checked_at=now_str, + last_checked_at=datetime.datetime.fromtimestamp(check["checked_at"]).isoformat() if check.get("checked_at") else None, last_success_at=last_success_str, enabled=pcfg.enabled, is_cold_spare=is_cold, diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index eb8aca1..d57a10a 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from fastapi.encoders import jsonable_encoder +from starlette.concurrency import run_in_threadpool from antigravity_provider.router.state_store import HubStateStore from antigravity_provider.router.action_handler import ActionExecutor @@ -225,7 +226,7 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to threading.Thread(target=func, name=name, daemon=True).start() actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web") - result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner, actor=actor) + result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor) if result.get("unknown"): raise HTTPException(status_code=404, detail="Неизвестное действие") @@ -412,6 +413,9 @@ _SNAPSHOT_REFRESH_SEC = 30 def _background_refresh_loop() -> None: from antigravity_provider.router.quota_collector import AccountQuotaService + from antigravity_provider.router.account_probe_service import AccountProbeService + AccountProbeService.get().enabled = True + AccountProbeService.get().tick() try: AccountQuotaService.get().fetch_all_configured(force=True) @@ -428,6 +432,7 @@ def _background_refresh_loop() -> None: while True: try: + AccountProbeService.get().tick() HubStateStore.get().refresh(force_scan=False) except Exception as exc: logger.warning("Snapshot refresh failed: %s", exc) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 12f1f8b..c70bcf6 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -637,9 +637,11 @@ function renderAccountsView() { 'opencode-go': 'OpenCode Go', claude: 'Claude (Anthropic)', grok: 'Grok (xAI)', - local: 'Local LLM', - 'local-llm': 'Local LLM', - 'llama.cpp': 'Local LLM (llama.cpp)', + nvidia: 'NVIDIA NIM', + openrouter: 'OpenRouter', + local: 'llama.cpp', + 'local-llm': 'llama.cpp', + 'llama.cpp': 'llama.cpp', ollama: 'Ollama', vllm: 'vLLM', }; @@ -647,7 +649,7 @@ function renderAccountsView() { const profilesByProv = currentSnapshot.profiles_by_provider || {}; let totalProfiles = 0; let visibleProfiles = 0; - let html = ''; + let html = '
'; for (const [providerId, profiles] of Object.entries(profilesByProv)) { if (providerFilter !== 'all' && providerFilter !== providerId) continue; @@ -695,7 +697,7 @@ function renderAccountsView() {${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.
` : ''} +${escapeHtml(modelStatus)}
+Выше — модели указанного сервера Ollama.
Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}
Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.
` : ''} + +