merge: A50 — аккаунты, обнаружение моделей и автоматическая проверка

Проверено ревьюером исполнением, все восемь замечаний владельца закрыты:

Чужой слот. Раньше add_account с profile_id=ag-w1 для nvidia возвращал
ok=True и клал аккаунт в слот Antigravity. Теперь отказ с причиной:
«Слот ag-w1 не принадлежит провайдеру nvidia». Свой слот принимается,
идентификатор выдаётся верный.

Группировка по провайдерам восстановлена: скрытие заголовков групп,
добавленное в A48 (display:contents + display:none), убрано.

Автоматическая проверка запускается при старте веб-сервера
(_start_background_refresh), состояние и списки моделей больше не ждут
ручного нажатия.

Облачные модели Ollama: эндпоинт не выдуман — ревьюер проверил запросом,
https://ollama.com/api/tags отвечает 200 и отдаёт 19 моделей
(gpt-oss:20b, kimi-k2.6, glm-5.1 и другие).

Локальный провайдер подписан llama.cpp вместо «Локальный сервер».

Показ хода при долгом опросе честный: «может занять до минуты на этап».

Конфликты с A49 разрешены сложением: правки дополняют друг друга —
поле пути к хранилищу Obsidian и поле периода проверки аккаунтов,
стили вкладки скиллов и стили групп провайдеров. В тесте свежести памяти
взят вариант A50: коммит извлекается из файла, а не зашит.

545 passed, 1 skipped, ruff clean, релизный гейт 10/10 и на конфигурации
владельца, и на пустой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-31 18:54:04 +07:00
commit e7aa896539
47 changed files with 1000 additions and 81 deletions

View file

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

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

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

View file

@ -0,0 +1,11 @@
# Логотипы моделей и провайдеров
2026-08-31, предварительная часть перед A50. База `17b368a`.
Причина отсутствия изображений: getProviderIcon ссылался на codex.png, claude.png и другие файлы, отсутствовавшие в web/static; реальные provider ids antigravity/claude также не совпадали с алиасами в таблице. Неизвестным провайдерам ошибочно назначалась llama.png.
Существующие OpenAI/Antigravity/OpenCode PNG скопированы без изменения из assets/providers. Набор дополнен локальными SVG Gemini, Claude, Grok, Ollama, NVIDIA, OpenRouter, DeepSeek, Qwen, Meta, Mistral, vLLM, LM Studio из [Lobe Icons](https://github.com/lobehub/lobe-icons). Лицензия MIT и точная ревизия сохранены рядом с файлами; загрузки с CDN при работе Hub нет.
Провайдер определяется по provider id, логотип модели — по её семейству. Название модели не изменяется; неизвестная модель получает нейтральный символ. Нет выдачи Ollama за бренд любой размещённой на нём модели. Добавлены значки назначенных моделей в карточки аккаунтов, рядом с выбором модели в маршрутизации и вместо общего символа на узлах графа. Единственное изменение workflow.js — отображение изображения; топология и настройки не затронуты.
Проверки: test_brand_icons.js проверяет алиасы, семейства, fallback и существование файлов; прежние два JS-набора проходят. Браузер подтвердил complete/naturalWidth для 16/16 изображений. Снимок: docs/screenshots/a50/brand-catalog.png. Это каталог, не доказательство сквозной работы A50 или авторизованных аккаунтов. Новый клиент находится в отдельном worktree A50; установленный Hub и preview A48 не заменены.

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -0,0 +1,94 @@
"""Non-blocking, de-duplicated health and model probes for configured accounts."""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional
class AccountProbeService:
_instance: Optional["AccountProbeService"] = None
_singleton_lock = threading.Lock()
def __init__(self) -> None:
self.enabled = False
self._next_check = 0.0
self._states: dict[str, dict[str, Any]] = {}
self._lock = threading.Lock()
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
@classmethod
def get(cls) -> "AccountProbeService":
with cls._singleton_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def state(self, profile_id: str) -> dict[str, Any]:
with self._lock:
return dict(self._states.get(profile_id, {"state": "never_checked"}))
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
if not self.enabled:
return False
with self._lock:
current = self._states.get(profile_id, {})
if current.get("state") == "checking":
return False
if not force and current.get("checked_at") and time.time() - current["checked_at"] < 30:
return False
self._states[profile_id] = {
**current, "state": "checking", "provider": provider,
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты",
}
self._pool.submit(self._run, provider, profile_id)
return True
def tick(self, now: Optional[float] = None) -> int:
from .settings_service import get_hub_settings
now = time.monotonic() if now is None else now
if not self.enabled or now < self._next_check:
return 0
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
return self.schedule_all(force=True)
def schedule_all(self, *, force: bool = False) -> int:
from .profile_manager import ProfileAuthManager
from .router_config import load_router_config
count = 0
for pid, pcfg in load_router_config().profiles.items():
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
count += int(self.schedule(pcfg.provider, pid, force=force))
return count
def _run(self, provider: str, profile_id: str) -> None:
from .action_handler import do_test_profile
from .model_discovery_service import ModelDiscoveryService
try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id)
HubStateStore.get().refresh(force_scan=True)
if provider == "ollama":
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
if cloud.get("is_stale"):
ModelDiscoveryService.get().discover_ollama_cloud()
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id)
success = bool(result.get("success"))
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения"
state = "working" if success else "failed"
except Exception as exc:
models, meta, state, message = None, {}, "failed", str(exc)
with self._lock:
self._states[profile_id] = {
"state": state, "provider": provider, "checked_at": time.time(),
"message": message, "models": models, "model_error": meta.get("error"),
"models_discovered_at": meta.get("discovered_at"),
}
try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass

View file

@ -35,7 +35,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
)
return ok, msg
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, discovered_models: Optional[List[str]] = None) -> Dict[str, Any]:
valid, reason = AutoAssigner.validate_slot(provider, profile_id)
if not valid:
return {"success": False, "error": reason}
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
@ -52,7 +55,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
model = pcfg.preferred_models[0] if pcfg.preferred_models else 'default'
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
t0 = time.time()
try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
@ -78,7 +81,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
t = threading.Thread(target=_call_invoke, daemon=True)
t.start()
t.join(timeout=10.0)
t.join(timeout=timeout)
el = round(time.time() - t0, 2)
@ -86,7 +89,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
return {
'success': False,
'duration_sec': el,
'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)',
'error': f'Превышено время ожидания ответа от провайдера ({timeout:g}с). Повторите проверку.',
}
if error_container:
@ -245,7 +248,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)
@ -343,6 +346,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)
@ -539,8 +544,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.
@ -608,6 +635,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
@ -703,7 +733,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}
@ -722,7 +754,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}'}
@ -746,6 +781,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 (
@ -863,8 +901,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}
@ -937,6 +974,23 @@ class ActionExecutor:
elif action == 'refresh_data':
return {'ok': True, 'message': 'Обновление данных'}
elif action == 'check_account':
from antigravity_provider.router.account_probe_service import AccountProbeService
if not AccountProbeService.get().enabled:
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
valid, reason = AutoAssigner.validate_slot(prov, pid)
if not valid:
return {'ok': False, 'message': reason}
started = AccountProbeService.get().schedule(prov, pid, force=True)
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
elif action == 'check_all_accounts':
from antigravity_provider.router.account_probe_service import AccountProbeService
if not AccountProbeService.get().enabled:
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
count = AccountProbeService.get().schedule_all(force=True)
return {'ok': True, 'message': f'Запущена проверка {count} аккаунтов'}
elif action == 'refresh_all':
if async_runner:
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')

View file

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

View file

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

View file

@ -25,6 +25,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",
@ -81,6 +82,11 @@ def get_hub_settings() -> Dict[str, Any]:
except Exception:
pass
try:
merged["account_check_interval_seconds"] = max(60, int(merged.get("account_check_interval_seconds", 300)))
except (ValueError, TypeError):
merged["account_check_interval_seconds"] = 300
# Normalize numeric types
try:
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))

View file

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

View file

@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder
from starlette.concurrency import run_in_threadpool
from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.action_handler import ActionExecutor
@ -225,7 +226,7 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
threading.Thread(target=func, name=name, daemon=True).start()
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner, actor=actor)
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
if result.get("unknown"):
raise HTTPException(status_code=404, detail="Неизвестное действие")
@ -491,6 +492,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)
@ -507,6 +511,7 @@ def _background_refresh_loop() -> None:
while True:
try:
AccountProbeService.get().tick()
HubStateStore.get().refresh(force_scan=False)
except Exception as exc:
logger.warning("Snapshot refresh failed: %s", exc)

View file

@ -675,9 +675,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',
};
@ -685,7 +687,7 @@ function renderAccountsView() {
const profilesByProv = currentSnapshot.profiles_by_provider || {};
let totalProfiles = 0;
let visibleProfiles = 0;
let html = '';
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button></div>';
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
@ -733,7 +735,7 @@ function renderAccountsView() {
<div class="provider-group">
<div class="provider-group-header">
<div class="provider-group-title">
<span class="provider-dot" style="color: var(--prov-${providerId.replace('openai-', '').replace('-go', '')})"></span>
<img class="brand-logo" src="/static/${getProviderIcon(providerId)}" width="24" height="24" alt="">
<span>${providerNames[providerId] || providerId}</span>
</div>
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
@ -825,11 +827,34 @@ function renderAccountCard(profile) {
</div>
</div>
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
${renderAccountCheck(profile)}
${quotaGridHtml}
</div>
`;
}
function renderAccountCheck(profile) {
const check = profile.connection_check || {};
const meta = profile.model_discovery || {};
const checking = check.state === 'checking';
const models = meta.models || [];
const timestamp = meta.discovered_at ? new Date(meta.discovered_at * 1000).toLocaleString('ru-RU') : '';
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
return `<div class="account-check" aria-live="polite">
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
<p>${escapeHtml(modelStatus)}</p>
<div class="account-models">${models.slice(0, 8).map(modelBrandLabel).join('')}</div>
${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button>
</div>`;
}
async function handleAccountProbe(profileId) {
await executeAction('check_account', {profile_id: profileId});
await fetchSnapshot();
}
function renderQuotaCell(bucket, unavailableReason) {
const remaining = bucket.remaining_percent;
let formattedValue = 'Н/Д';
@ -1029,14 +1054,32 @@ function renderOverviewView() {
function getProviderIcon(provider) {
const map = {
'openai-codex': 'codex.png',
'google-antigravity': 'антигравити.png',
'opencode-go': 'opencode.png',
'anthropic-claude': 'claude.png',
'deepseek': 'deepseek.png',
'grok': 'grok.jfif'
'openai-codex':'openai.png', openai:'openai.png', codex:'openai.png',
antigravity:'antigravity.png', 'google-antigravity':'antigravity.png',
'opencode-go':'opencode.png', opencode:'opencode.png',
claude:'claude-color.svg', 'anthropic-claude':'claude-color.svg', anthropic:'claude-color.svg',
grok:'grok.svg', xai:'grok.svg', ollama:'ollama.svg',
nvidia:'nvidia-color.svg', openrouter:'openrouter.svg',
deepseek:'deepseek-color.svg', vllm:'vllm-color.svg', lmstudio:'lmstudio.svg',
local:'unknown.svg', 'llama.cpp':'unknown.svg',
};
return map[provider] || 'llama.png';
return 'brands/'+(map[String(provider || '').toLowerCase()] || 'unknown.svg');
}
function getModelIcon(model) {
const name=String(model || '').toLowerCase().split('/').at(-1);
const family=[
[/^gemini(?:[-.:]|$)/,'gemini-color.svg'],
[/^claude(?:[-.:]|$)/,'claude-color.svg'],[/^(?:gpt|chatgpt|codex|o[134])(?:[-.:0-9]|$)/,'openai.png'],
[/^grok(?:[-.:]|$)/,'grok.svg'],[/^deepseek(?:[-.:]|$)/,'deepseek-color.svg'],
[/^qwen(?:[-.:0-9]|$)/,'qwen-color.svg'],[/^(?:llama|meta-llama)(?:[-.:0-9]|$)/,'meta-color.svg'],
[/^(?:mistral|mixtral|codestral|devstral|magistral)(?:[-.:0-9]|$)/,'mistral-color.svg'],
].find(([pattern])=>pattern.test(name));
return 'brands/'+(family?.[1] || 'unknown.svg');
}
function modelBrandLabel(model) {
return `<span class="model-brand"><img class="brand-logo" src="/static/${getModelIcon(model)}" alt=""><span>${escapeHtml(model || 'Модель: Н/Д')}</span></span>`;
}
function renderRoutingView() { renderAccountRouting(); }
@ -1493,6 +1536,7 @@ function renderSettingsView() {
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const accountIntervalInput = document.getElementById('setting-account-check-interval');
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
@ -1503,6 +1547,17 @@ function renderSettingsView() {
if (emailMaskingSel && s.email_masking_mode) {
emailMaskingSel.value = s.email_masking_mode;
}
if (accountIntervalInput && !accountIntervalInput.dataset.loaded) {
accountIntervalInput.dataset.loaded = 'loading';
fetch('/api/settings', {headers: authToken ? {'X-Hub-Token': authToken} : {}})
.then(response => { if (!response.ok) throw new Error('Настройки недоступны'); return response.json(); })
.then(settings => {
if (!Number.isFinite(Number(settings.account_check_interval_seconds))) throw new Error('Период не передан сервером');
accountIntervalInput.value = settings.account_check_interval_seconds;
accountIntervalInput.disabled = false;
accountIntervalInput.dataset.loaded = 'yes';
}).catch(error => { accountIntervalInput.placeholder = 'Н/Д: ' + error.message; accountIntervalInput.dataset.loaded = ''; });
}
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
monitorIntervalInput.value = s.monitoring_interval_seconds;
}
@ -1519,8 +1574,10 @@ async function saveHubServerSettings() {
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
const accountIntervalInput = document.getElementById('setting-account-check-interval');
const newSettings = {};
if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
@ -1699,7 +1756,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const qs = profile.quota_snapshot;
const buckets = (qs && qs.buckets) ? qs.buckets : [];
@ -1721,13 +1778,15 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')" ${profile.connection_check?.state === 'checking' ? 'disabled' : ''}>${profile.connection_check?.state === 'checking' ? 'Запрашивается список моделей…' : '↻ Запросить список моделей'}</button>
</div>
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
// Local request options section
let requestOptionsHtml = '';
if (profile.provider === 'local') {
@ -1782,6 +1841,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
@ -2293,7 +2353,7 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
async function handleRefreshProviderModels(providerId, profileId = null) {
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
const res = await executeAction('refresh_models', { provider: providerId });
const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' });
if (res && res.ok) {
showToast('Запрос обновления моделей отправлен', 'success');
if (profileId) {
@ -2314,7 +2374,7 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const qs = profile.quota_snapshot;
const buckets = (qs && qs.buckets) ? qs.buckets : [];
@ -2336,13 +2396,14 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')" ${profile.connection_check?.state === 'checking' ? 'disabled' : ''}>${profile.connection_check?.state === 'checking' ? 'Запрашивается список моделей…' : '↻ Запросить список моделей'}</button>
</div>
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
@ -2426,7 +2487,7 @@ function openAgentModelModal(roleId, profileId) {
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
@ -2446,7 +2507,7 @@ function openAgentModelModal(roleId, profileId) {
` : `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')"> Запросить список моделей</button>
</div>
@ -2501,10 +2562,10 @@ async function handleTestProfile(profileId) {
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
}
const res = await executeAction('test', { profile_id: profileId });
const res = await executeAction('check_account', { profile_id: profileId });
if (feedbackArea) {
if (res && res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Тест успешно пройден')}</div>`;
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Проверка запущена; результат появится в карточке')}</div>`;
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
}
@ -2526,6 +2587,10 @@ function openAddAccountWizard() {
}
function showWizardStep1() {
stopDeviceAuthPolling();
stopRedirectAuthPolling();
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined;
window._wiz_provider = undefined;
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
elements.modalBody.innerHTML = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
@ -2603,6 +2668,12 @@ function showWizardStep1() {
}
function showWizardStep2(providerId) {
if (window._wiz_provider !== providerId) {
window._wiz_device_profile = undefined;
window._wiz_base_url = undefined;
window._wiz_token = undefined;
}
window._wiz_provider = providerId;
let bodyHtml = '';
let footerHtml = '';
@ -2778,14 +2849,10 @@ function proceedToWizardStep3(providerId) {
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
if (isDeviceAuthFlow) {
const deviceSlot = document.getElementById('wiz-device-slot');
if (deviceSlot && deviceSlot.value) {
window._wiz_device_profile = deviceSlot.value;
}
window._wiz_device_profile = deviceSlot?.value || '';
} else if (isRedirectAuthFlow) {
const redirectSlot = document.getElementById('wiz-redirect-slot');
if (redirectSlot && redirectSlot.value) {
window._wiz_device_profile = redirectSlot.value;
}
window._wiz_device_profile = redirectSlot?.value || '';
}
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
showWizardStep3(providerId);
@ -2837,6 +2904,10 @@ function showWizardStep3(providerId) {
}
async function finishAddAccount(providerId) {
if (window._wiz_saving) return;
window._wiz_saving = true;
const finishButton = elements.modalFooter?.querySelector(".btn-primary");
if (finishButton) finishButton.disabled = true;
const roleSelect = document.getElementById('wiz-target-role');
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
@ -2857,7 +2928,7 @@ async function finishAddAccount(providerId) {
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохранение аккаунта и запуск проверки. Опрос провайдера может занять до минуты на этап.</div>`;
}
const payload = {
@ -2873,10 +2944,12 @@ async function finishAddAccount(providerId) {
payload.token = window._wiz_token;
}
const res = await executeAction('add_account', payload);
let res;
try { res = await executeAction('add_account', payload); }
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
if (res && res.ok) {
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
closeModal();
fetchSnapshot();
} else {
@ -2914,6 +2987,7 @@ async function startDeviceAuth(providerId) {
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
if (window._wiz_provider !== providerId) return;
if (!res || !res.ok) {
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
return;
@ -2993,7 +3067,7 @@ function profilesInRouting() {
function buildSlotOptions(providerId) {
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
if (!profiles.length) {
return '<option value="">Список слотов ещё не получен</option>';
return '<option value="">Новый свободный слот — автоматически</option>';
}
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
// ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый
@ -3018,7 +3092,7 @@ function buildSlotOptions(providerId) {
else used.push(opt);
});
// Свободные слоты с ролью — первыми: именно они дают работающий маршрут.
return free.concat(used, idle).join('');
return '<option value="">Новый свободный слот — автоматически</option>' + free.concat(used, idle).join('');
}
let _redirectAuthTimer = null;
@ -3056,6 +3130,7 @@ async function startRedirectAuth(providerId) {
provider: providerId,
profile_id: chosen || undefined,
});
if (window._wiz_provider !== providerId) return;
if (!res || !res.ok) {
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
return;

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,3 @@
# Brand assets
Existing PNGs copied unchanged from assets/providers. Other SVG marks: https://github.com/lobehub/lobe-icons (MIT), revision 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75; license bundled. Served locally without CDN. SVG geometry unchanged. Trademarks belong to their owners.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
<!doctype html><html lang="ru"><meta charset="utf-8"><title>Hermes Hub — логотипы моделей и провайдеров</title><style>body{background:#07120f;color:#e7dcc1;font:14px system-ui;margin:32px}h1{font-size:22px}section{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}article{border:1px solid #51452c;padding:20px;display:flex;align-items:center;gap:16px}img{width:48px;height:48px;padding:6px;background:white;border-radius:6px;object-fit:contain}</style><h1>Логотипы моделей и провайдеров</h1><p>Локальные файлы. Первые три — существующий набор; остальные — масштабируемые SVG. Последний значок используется только для неизвестной модели.</p><section><article><img src="openai.png" alt="openai.png"><span>openai.png</span></article><article><img src="antigravity.png" alt="antigravity.png"><span>antigravity.png</span></article><article><img src="opencode.png" alt="opencode.png"><span>opencode.png</span></article><article><img src="gemini-color.svg" alt="gemini-color.svg"><span>gemini-color.svg</span></article><article><img src="claude-color.svg" alt="claude-color.svg"><span>claude-color.svg</span></article><article><img src="grok.svg" alt="grok.svg"><span>grok.svg</span></article><article><img src="ollama.svg" alt="ollama.svg"><span>ollama.svg</span></article><article><img src="nvidia-color.svg" alt="nvidia-color.svg"><span>nvidia-color.svg</span></article><article><img src="openrouter.svg" alt="openrouter.svg"><span>openrouter.svg</span></article><article><img src="deepseek-color.svg" alt="deepseek-color.svg"><span>deepseek-color.svg</span></article><article><img src="qwen-color.svg" alt="qwen-color.svg"><span>qwen-color.svg</span></article><article><img src="meta-color.svg" alt="meta-color.svg"><span>meta-color.svg</span></article><article><img src="mistral-color.svg" alt="mistral-color.svg"><span>mistral-color.svg</span></article><article><img src="vllm-color.svg" alt="vllm-color.svg"><span>vllm-color.svg</span></article><article><img src="lmstudio.svg" alt="lmstudio.svg"><span>lmstudio.svg</span></article><article><img src="unknown.svg" alt="unknown.svg"><span>unknown.svg</span></article></section></html>

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LM Studio</title><path d="M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z" fill-opacity=".3"></path><path d="M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z"></path></svg>

After

Width:  |  Height:  |  Size: 382 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="#746b54" stroke-width="1.5" d="M8 3h8v4h5v10h-5v4H8v-4H3V7h5z"/><circle cx="12" cy="12" r="3" fill="none" stroke="#746b54" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 235 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>vLLM</title><path d="M0 4.973h9.324V23L0 4.973z" fill="#FDB515"></path><path d="M13.986 4.351L22.378 0l-6.216 23H9.324l4.662-18.649z" fill="#30A2FF"></path></svg>

After

Width:  |  Height:  |  Size: 286 B

View file

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

View file

@ -1823,9 +1823,14 @@ body[data-theme="medium"] .nav-icon { stroke:var(--canvas-ink); }
.account-summary strong { display:block; font-size:24px; font-weight:500; margin:6px 0; }
.account-summary span,.account-summary small { color:var(--text-muted); font-size:11px; }
.accounts-groups-container { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.provider-group,.accounts-grid { display:contents; }
.provider-group-header { display:none; }
.account-card { min-width:0; padding:16px; }
.provider-group { grid-column:1 / -1; min-width:0; }
.provider-group-header { display:flex; justify-content:space-between; align-items:center; margin:12px 0; }
.accounts-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr)); gap:12px; }
.account-card { min-width:0; padding:16px; height:auto; min-height:164px; gap:10px; }
.account-card-header { flex-wrap:wrap; }
.account-badges { min-width:0; flex-wrap:wrap; }
.account-card .badge-status { white-space:normal; overflow-wrap:anywhere; }
.badge-status.unhealthy { color:var(--status-error); }
.account-card-header { gap:8px; }
.account-provider-tag { display:flex; align-items:center; gap:10px; }
.account-provider-tag img { width:30px; height:30px; object-fit:contain; }
@ -2059,3 +2064,15 @@ body[data-theme="medium"] .nav-item.active .nav-icon { stroke:var(--text-accent)
word-break: break-all;
}
/* Brand marks are local assets, separate for a provider and the model it hosts. */
.brand-logo,.account-provider-tag img,.available-account-card>img { width:24px; height:24px; object-fit:contain; padding:3px; border-radius:4px; background:#fff; flex:none; }
.model-brand { display:inline-flex; align-items:center; gap:6px; min-width:0; }
.model-brand>span { overflow-wrap:anywhere; }
.account-models { display:flex; gap:8px; flex-wrap:wrap; margin:10px 0; font-size:11px; }
.account-models:empty { display:none; }
.route-model-with-logo { display:flex; align-items:center; gap:6px; min-width:0; }
.route-model-with-logo select { min-width:0; }
.agent-node-icon .brand-logo { width:27px; height:27px; }
.account-check { font-size:12px; line-height:1.5; color:var(--text-secondary); overflow-wrap:anywhere; }
.badge-status.checking { color:var(--text-accent); }

View file

@ -212,7 +212,7 @@ function renderWorkflowNodes(workflow) {
const assignment = `${cfg.model || 'Модель: Н/Д'}${cfg.account || 'Аккаунт: Н/Д'}`;
return `<article class="workflow-node ${wfEscape(agent.runtime_state)} ${workflowUi.selectedAgentId === agent.id ? 'selected' : ''}" data-agent-id="${wfEscape(agent.id)}" style="left:${Number(pos.x) || 0}px;top:${Number(pos.y) || 0}px">
<button class="workflow-port in" aria-label="Вход"></button><button class="workflow-port out" aria-label="Создать связь"></button>
<span class="agent-node-icon" aria-hidden="true"></span><h3>${wfEscape(agent.name)}</h3><p title="${wfEscape(agent.agent_file)}">${wfEscape(agent.agent_file?.split('/').pop() || 'Agent File: Н/Д')}</p><p class="node-assignment" title="${wfEscape(cfg.unavailable_reason || assignment)}">${wfEscape(assignment)}</p>
<span class="agent-node-icon" aria-hidden="true"><img class="brand-logo" src="/static/${getModelIcon(cfg.model)}" alt=""></span><h3>${wfEscape(agent.name)}</h3><p title="${wfEscape(agent.agent_file)}">${wfEscape(agent.agent_file?.split('/').pop() || 'Agent File: Н/Д')}</p><p class="node-assignment" title="${wfEscape(cfg.unavailable_reason || assignment)}">${wfEscape(assignment)}</p>
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
</article>`;
}).join('');

View file

@ -135,7 +135,7 @@ function renderAccountRouting() {
const profile = profiles[node.profile_id] || {profile_id:node.profile_id,provider:node.provider};
const quota = profile.quota_snapshot || snapshot.quotas?.[node.profile_id] || {};
const reset = (quota.buckets || []).map(b => b.reset_time_formatted || b.reset_after_formatted).filter(Boolean).join('; ');
return `<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}"><div class="drag-handle">⠿</div><div class="route-priority">${index ? 'Резерв '+index : 'Основной'}</div><div><strong>${escapeHtml(profile.display_name || node.profile_id)}</strong><div class="text-muted">${escapeHtml(node.account_identity || profile.account_identity || node.provider)}</div></div><div><select data-route-model="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Модель ${escapeHtml(node.profile_id)}">${modelOptions(snapshot,profile,node.model)}</select></div><div>${routeQuota(profile,snapshot)}</div><div class="text-muted">${escapeHtml(reset || 'Н/Д: нет времени сброса')}</div><div><span class="status-dot ${healthDotClass(node.status)}"></span> ${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? '<br>Используется' : ''}</div><button class="route-remove" data-remove-profile="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Удалить из маршрута">×</button></div>`;
return `<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}"><div class="drag-handle">⠿</div><div class="route-priority">${index ? 'Резерв '+index : 'Основной'}</div><div><strong>${escapeHtml(profile.display_name || node.profile_id)}</strong><div class="text-muted">${escapeHtml(node.account_identity || profile.account_identity || node.provider)}</div></div><div class="route-model-with-logo"><img class="brand-logo" src="/static/${getModelIcon(node.model)}" alt=""><select data-route-model="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Модель ${escapeHtml(node.profile_id)}">${modelOptions(snapshot,profile,node.model)}</select></div><div>${routeQuota(profile,snapshot)}</div><div class="text-muted">${escapeHtml(reset || 'Н/Д: нет времени сброса')}</div><div><span class="status-dot ${healthDotClass(node.status)}"></span> ${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? '<br>Используется' : ''}</div><button class="route-remove" data-remove-profile="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Удалить из маршрута">×</button></div>`;
}).join('');
return `<section class="role-section"><header class="role-header"><div><h4>${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}</h4><p class="text-muted">${escapeHtml(agent?.description || role?.role_description_ru || '')}</p></div><button class="btn btn-secondary btn-sm" data-add-role="${escapeHtml(roleId)}">+ Добавить аккаунт</button></header><div class="grid-header"><span></span><span>Приоритет</span><span>Аккаунт</span><span>Модель</span><span>Квота · остаток</span><span>Сброс</span><span>Статус</span><span></span></div><div class="role-chain-list">${rows || '<p class="inspector-value">Аккаунты не назначены. Добавьте подключённый аккаунт.</p>'}</div><div class="drop-zone" data-role="${escapeHtml(roleId)}">+ Перетащите аккаунт в конец маршрута</div><p class="inspector-value">Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям</p></section>`;
}).join('') || '<p class="view-header-note">Агенты ещё не созданы. Добавьте агента на «Обзоре».</p>';
@ -200,7 +200,7 @@ function arrangeSettingsPanels() {
const first = view.querySelector('.settings-card');
const groups = [
['Общие настройки',['setting-theme']],
['Управление квотами',['setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
];
for (const [title,ids] of groups) {

View file

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

View file

@ -111,7 +111,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
},
)
assert res["ok"] is True
assert "успешно подключен" in res["message"]
assert "сохранён" in res["message"]
# Verify profile created and auth saved
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
@ -137,7 +137,7 @@ def test_p0_1_add_account_nvidia_default_base_url():
},
)
assert res["ok"] is True
assert "успешно подключен" in res["message"]
assert "сохранён" in res["message"]
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
assert auth is not None

199
tests/test_accounts_a50.py Normal file
View file

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

13
tests/test_brand_icons.js Normal file
View file

@ -0,0 +1,13 @@
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const root='src/antigravity_provider/router/web/static/';
const source=fs.readFileSync(root+'app.js','utf8');
const context=vm.createContext({});
vm.runInContext(source.slice(source.indexOf('function getProviderIcon('),source.indexOf('function modelBrandLabel(')),context);
for(const provider of ['openai-codex','antigravity','google-antigravity','opencode-go','claude','anthropic-claude','grok','ollama','local','vllm','openrouter','nvidia','unknown'])assert(fs.existsSync(root+context.getProviderIcon(provider)),provider);
for(const model of ['gemini-3.7-flash','claude-opus-4-6','gpt-oss:20b','qwen3:32b','org/deepseek-r1','meta-llama/llama-3.3','grok-4','mistral-small','unknown',''])assert(fs.existsSync(root+context.getModelIcon(model)),model);
assert(context.getModelIcon('qwen3:32b').includes('qwen'));
assert(context.getModelIcon('gemini-3.7-flash').includes('gemini'));
assert(!context.getModelIcon('gemini-3.7-flash').includes('ollama'));
assert(context.getModelIcon('custom-model').includes('unknown'));
assert(context.getProviderIcon('custom-provider').includes('unknown'));
console.log('Brand assets: provider aliases, model families, unknown fallback and all referenced files verified');

View file

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

View file

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