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>
183
agents/inbox/2026-08-31-A50-accounts-discovery.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# Задание A50: аккаунты, обнаружение моделей и состояние проверки
|
||||||
|
|
||||||
|
## Дата поступления
|
||||||
|
2026-08-31
|
||||||
|
|
||||||
|
## База
|
||||||
|
|
||||||
|
`origin/main` (`17b368a`).
|
||||||
|
|
||||||
|
```
|
||||||
|
git fetch origin --prune
|
||||||
|
git checkout -b antigravity/a50-accounts-discovery origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
В `main` напрямую не пушить.
|
||||||
|
|
||||||
|
## Порядок исполнения
|
||||||
|
|
||||||
|
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
|
||||||
|
|
||||||
|
Зона: Python-часть провайдеров и экран «Аккаунты». С A49 (субагенты, скиллы, память) не пересекается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Восемь замечаний владельца после установки сборки `17b368a`. Причины найдены и проверены ревьюером исполнением — заново не выяснять.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-1. Аккаунт сохраняется в чужой слот и рапортует об успехе
|
||||||
|
|
||||||
|
Самое серьёзное. Проверено вызовом:
|
||||||
|
|
||||||
|
```
|
||||||
|
add_account provider=nvidia profile_id=ag-w1 token=k
|
||||||
|
→ ok=True «Сервер nvidia (ag-w1) успешно подключен»
|
||||||
|
```
|
||||||
|
|
||||||
|
Аккаунт NVIDIA записан в слот Antigravity. Бэкенд **не проверяет, что слот принадлежит провайдеру**.
|
||||||
|
|
||||||
|
Как владелец в это попадает: в мастере для OpenRouter и NVIDIA список слотов пуст — `buildSlotOptions` возвращает «Список слотов ещё не получен». Дальше в `app.js` слот выбирается так:
|
||||||
|
|
||||||
|
```js
|
||||||
|
selectedProfileId = window._wiz_device_profile ?? (deviceSlot?.value || redirectSlot?.value || '');
|
||||||
|
```
|
||||||
|
|
||||||
|
`??` пропускает пустую строку, поэтому побеждает `_wiz_device_profile`, оставшийся **от предыдущей попытки подключения другого провайдера**. Владелец пробовал Antigravity, потом NVIDIA — и NVIDIA легла в `ag-w1`.
|
||||||
|
|
||||||
|
Отсюда жалобы 3 и 4: «нвидиа не добавляется», «опенроутер так же не добавляется». Он добавляется — не туда.
|
||||||
|
|
||||||
|
Требуется:
|
||||||
|
|
||||||
|
1. **Бэкенд отклоняет чужой слот.** `profile_id`, не принадлежащий провайдеру, — отказ с причиной, а не `ok: True`.
|
||||||
|
2. **Состояние мастера сбрасывается** при возврате к выбору провайдера и при открытии нового подключения. Остатков от прошлой попытки быть не должно.
|
||||||
|
3. **Список слотов для OpenRouter и NVIDIA** заполняется или поле не показывается вовсе, раз слот выдаётся автоматически.
|
||||||
|
4. **Прогнать сквозной путь** для обоих провайдеров с заведомо неверным ключом: профиль создаётся с правильным идентификатором, проверка подключения даёт внятную ошибку авторизации.
|
||||||
|
|
||||||
|
## P0-2. Вернуть разделение по провайдерам
|
||||||
|
|
||||||
|
Владелец: «нет разделения по аккаунтам. Как раньше: Антигравити и снизу все аккаунты аги, Грок и снизу все аккаунты грока».
|
||||||
|
|
||||||
|
Разметка группировки цела (`provider-group`, `provider-group-header`, счётчик). Её **скрыл A48**, добавив в `style.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.provider-group,.accounts-grid { display:contents; }
|
||||||
|
.provider-group-header { display:none; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Вернуть группы: заголовок провайдера, его значок, число аккаунтов, под ним карточки. Вёрстку A48 в остальном не ломать — это правка одного места в стилях, а не переделка экрана.
|
||||||
|
|
||||||
|
## P0-3. Проверка должна запускаться сама
|
||||||
|
|
||||||
|
Владелец: «везде пишет Статус: Не проверялся и, я так понимаю, ничего не работает».
|
||||||
|
|
||||||
|
Ярлык честен, но вывод владельца неверен, и это вина интерфейса. Проверено:
|
||||||
|
|
||||||
|
```
|
||||||
|
unified_health.py:499 precord.last_success is None → «Не проверялся»
|
||||||
|
server.py, hermes_hub_app.py — вызова проверки при запуске НЕТ
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть состояние меняется только после **успешного вызова через профиль**, а вызвать его автоматически некому. Пока владелец не нажмёт «Проверить подключение» вручную для каждого аккаунта, все останутся «Не проверялся» навсегда.
|
||||||
|
|
||||||
|
Требуется:
|
||||||
|
|
||||||
|
1. **Проверка запускается сама**: сразу после подключения аккаунта и периодически. Период настраивается, значение по умолчанию обосновать.
|
||||||
|
2. **Не блокировать интерфейс**: проверка идёт в фоне, состояние обновляется по мере готовности.
|
||||||
|
3. **Различать три состояния явно**: «не проверялся», «проверяется», «проверен: работает / не работает с причиной». Сейчас первое и третье сливаются в одно.
|
||||||
|
4. **Кнопка ручной проверки остаётся** — и для отдельного аккаунта, и для всех сразу.
|
||||||
|
|
||||||
|
## P0-4. Списки моделей не подтягиваются ни у одного аккаунта
|
||||||
|
|
||||||
|
Та же причина: обнаружение запускается только по явному действию. У Grok, Antigravity и Ollama владелец видит «Список моделей ещё не получен от провайдера».
|
||||||
|
|
||||||
|
1. **Запрашивать список при подключении** аккаунта и при периодической проверке.
|
||||||
|
2. **Кэшировать** с временем получения; показывать, когда список снят.
|
||||||
|
3. **Ошибка обнаружения доходит до интерфейса с текстом ответа сервера.** «Не получен» и «сервер отказал: <текст>» — разные сообщения.
|
||||||
|
4. **Кнопка «Запросить список моделей» показывает ход** и результат, а не остаётся в прежнем виде.
|
||||||
|
|
||||||
|
## P0-5. Облачные модели Ollama
|
||||||
|
|
||||||
|
Ветка обнаружения Ollama после A42 читает адрес из профиля и опрашивает `/api/tags`. Это **только локально скачанные модели**; облачных там нет по устройству эндпоинта.
|
||||||
|
|
||||||
|
1. Выяснить **по действующей документации Ollama**, как получить список облачных моделей учётной записи и что для этого нужно. **Эндпоинт не выдумывать.**
|
||||||
|
2. Показывать локальные и облачные раздельно, чтобы владелец видел, что откуда.
|
||||||
|
3. Способ не подтверждён документацией — так и написать в отчёте, а в интерфейсе показать `Н/Д` с причиной. Это принимается; выдуманный адрес — нет.
|
||||||
|
|
||||||
|
## P0-6. Долгая загрузка данных аккаунта
|
||||||
|
|
||||||
|
Владелец: «у Грока и Антигравити долго подгружаются данные аккаунтов… чтобы не начали по несколько раз подключать один аккаунт».
|
||||||
|
|
||||||
|
Измеренные пределы ожидания:
|
||||||
|
|
||||||
|
```
|
||||||
|
quota_collector таймауты 15, 20 и 30 секунд на запрос
|
||||||
|
agy_subprocess таймаут 60 секунд — Antigravity ходит через CLI agy
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть до минуты ожидания — это штатное поведение, а не поломка. Проблема в том, что владелец этого не видит.
|
||||||
|
|
||||||
|
1. **Показывать ход**: «идёт опрос провайдера, это может занять до минуты» с указанием, какой именно аккаунт опрашивается.
|
||||||
|
2. **Не давать запустить подключение того же аккаунта повторно**, пока предыдущее не завершилось.
|
||||||
|
3. **По истечении ожидания** — внятное сообщение с причиной и предложением повторить, а не молчание.
|
||||||
|
4. Ускорять там, где это возможно без риска: параллельный опрос независимых аккаунтов вместо последовательного. Если ускорение невозможно — так и написать, честное объяснение задержки достаточно.
|
||||||
|
|
||||||
|
## P0-7. Локальный сервер называть тем, что там работает
|
||||||
|
|
||||||
|
Владелец: «локальный сервер это llama, так и надо подписывать».
|
||||||
|
|
||||||
|
Сейчас `auto_assigner.py:83` даёт провайдеру `local` подпись «Локальный сервер», а профилям — «Локальный сервер 1» и «Локальный сервер 2».
|
||||||
|
|
||||||
|
1. Провайдер `local` подписывать по движку: `llama.cpp`. Для `ollama` и `vllm` подписи уже свои — не трогать.
|
||||||
|
2. **Если движок определяется по ответу сервера** — брать оттуда. Иначе подпись по типу профиля, без выдумок.
|
||||||
|
3. Проверить, что переименование не ломает сохранённые конфигурации: идентификаторы профилей не меняются, меняется только отображаемое имя.
|
||||||
|
|
||||||
|
## P0-8. Аудит вторым проходом
|
||||||
|
|
||||||
|
1. **Проверить чужой слот целенаправленно**: подключить NVIDIA после начатой и брошенной попытки Antigravity. Аккаунт обязан лечь в свой слот.
|
||||||
|
2. **Убедиться, что мнимых успехов не осталось**: ни одно действие, положившее данные не туда, не возвращает `ok: True`.
|
||||||
|
3. **Открыть экран «Аккаунты»** и увидеть группы по провайдерам. Скриншот приложить.
|
||||||
|
4. **Дождаться автоматической проверки** и убедиться, что состояние сменилось само, без нажатий.
|
||||||
|
5. **Проверить, что «не проверялся» и «проверен, не работает» различимы** на экране.
|
||||||
|
6. **Эндпоинт облачных моделей Ollama** сверить с документацией.
|
||||||
|
7. **Побочные изменения** объяснить.
|
||||||
|
8. **Пропущенный пункт назвать пропущенным.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Ключи владельца не запрашивать и в репозиторий не класть.
|
||||||
|
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||||
|
- Вёрстку A48 не переделывать: по группам — правка стилей, не переработка экрана.
|
||||||
|
- Службы `qwen-coder` и `qwen-compressor` не трогать.
|
||||||
|
- Версию `0.1.1` не поднимать.
|
||||||
|
- Правило честности без исключений: не измерено — `Н/Д` с причиной.
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
1. Ветка в `origin`, `git status` чист.
|
||||||
|
2. Аккаунт не сохраняется в слот чужого провайдера; попытка отклоняется с причиной; проверено.
|
||||||
|
3. Состояние мастера сбрасывается между попытками; проверено сквозным путём.
|
||||||
|
4. OpenRouter и NVIDIA подключаются с правильными идентификаторами профилей.
|
||||||
|
5. На экране «Аккаунты» вернулись группы по провайдерам; скриншот приложен.
|
||||||
|
6. Проверка запускается автоматически после подключения и периодически; состояние меняется без ручных нажатий.
|
||||||
|
7. «Не проверялся», «проверяется» и «проверен, не работает» различимы.
|
||||||
|
8. Списки моделей подтягиваются автоматически; ошибка доходит с текстом сервера.
|
||||||
|
9. Облачные модели Ollama получены либо честно объявлены недоступными с причиной.
|
||||||
|
10. Долгая загрузка показывает ход; повторный запуск того же подключения невозможен.
|
||||||
|
11. Локальный провайдер подписан по движку.
|
||||||
|
12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
||||||
|
13. Память проекта в AI-Memory обновлена.
|
||||||
|
14. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||||
|
|
||||||
|
## Главное
|
||||||
|
|
||||||
|
Владелец смотрит на четыре подключённых аккаунта, у всех «Не проверялся», ни у одного нет списка моделей, и делает единственно возможный вывод: ничего не работает. На деле проверка просто ни разу не запускалась, потому что запускать её некому.
|
||||||
|
|
||||||
|
Рядом дефект, который хуже: аккаунт NVIDIA сохраняется в слот Antigravity и докладывает об успехе. Владелец видит, что аккаунт «не добавился», и пробует снова — а в конфигурации накапливается мусор.
|
||||||
|
|
||||||
|
## Порядок сдачи
|
||||||
|
Передать точный `FINAL_COMMIT_SHA`.
|
||||||
66
docs/reports/A50.md
Normal file
|
|
@ -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 не менялся.
|
||||||
11
docs/reports/model-provider-logos.md
Normal 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 не заменены.
|
||||||
BIN
docs/screenshots/a50/accounts-checked.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/screenshots/a50/accounts-checking.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/screenshots/a50/accounts-nvidia-dark.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/screenshots/a50/accounts-nvidia-light.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/screenshots/a50/accounts-nvidia-medium.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/screenshots/a50/brand-catalog.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/screenshots/a50/openrouter-wizard-result.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
94
src/antigravity_provider/router/account_probe_service.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Non-blocking, de-duplicated health and model probes for configured accounts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AccountProbeService:
|
||||||
|
_instance: Optional["AccountProbeService"] = None
|
||||||
|
_singleton_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.enabled = False
|
||||||
|
self._next_check = 0.0
|
||||||
|
self._states: dict[str, dict[str, Any]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> "AccountProbeService":
|
||||||
|
with cls._singleton_lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def state(self, profile_id: str) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._states.get(profile_id, {"state": "never_checked"}))
|
||||||
|
|
||||||
|
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
|
||||||
|
if not self.enabled:
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
current = self._states.get(profile_id, {})
|
||||||
|
if current.get("state") == "checking":
|
||||||
|
return False
|
||||||
|
if not force and current.get("checked_at") and time.time() - current["checked_at"] < 30:
|
||||||
|
return False
|
||||||
|
self._states[profile_id] = {
|
||||||
|
**current, "state": "checking", "provider": provider,
|
||||||
|
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты",
|
||||||
|
}
|
||||||
|
self._pool.submit(self._run, provider, profile_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tick(self, now: Optional[float] = None) -> int:
|
||||||
|
from .settings_service import get_hub_settings
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
if not self.enabled or now < self._next_check:
|
||||||
|
return 0
|
||||||
|
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
|
||||||
|
return self.schedule_all(force=True)
|
||||||
|
|
||||||
|
def schedule_all(self, *, force: bool = False) -> int:
|
||||||
|
from .profile_manager import ProfileAuthManager
|
||||||
|
from .router_config import load_router_config
|
||||||
|
count = 0
|
||||||
|
for pid, pcfg in load_router_config().profiles.items():
|
||||||
|
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
|
||||||
|
count += int(self.schedule(pcfg.provider, pid, force=force))
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _run(self, provider: str, profile_id: str) -> None:
|
||||||
|
from .action_handler import do_test_profile
|
||||||
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
try:
|
||||||
|
from .state_store import HubStateStore
|
||||||
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id)
|
||||||
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
if provider == "ollama":
|
||||||
|
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
|
||||||
|
if cloud.get("is_stale"):
|
||||||
|
ModelDiscoveryService.get().discover_ollama_cloud()
|
||||||
|
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
|
||||||
|
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id)
|
||||||
|
success = bool(result.get("success"))
|
||||||
|
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения"
|
||||||
|
state = "working" if success else "failed"
|
||||||
|
except Exception as exc:
|
||||||
|
models, meta, state, message = None, {}, "failed", str(exc)
|
||||||
|
with self._lock:
|
||||||
|
self._states[profile_id] = {
|
||||||
|
"state": state, "provider": provider, "checked_at": time.time(),
|
||||||
|
"message": message, "models": models, "model_error": meta.get("error"),
|
||||||
|
"models_discovered_at": meta.get("discovered_at"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
from .state_store import HubStateStore
|
||||||
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -35,7 +35,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||||||
)
|
)
|
||||||
return ok, msg
|
return ok, msg
|
||||||
|
|
||||||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, discovered_models: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, profile_id)
|
||||||
|
if not valid:
|
||||||
|
return {"success": False, "error": reason}
|
||||||
config = load_router_config()
|
config = load_router_config()
|
||||||
pcfg = config.get_profile(profile_id)
|
pcfg = config.get_profile(profile_id)
|
||||||
if not pcfg:
|
if not pcfg:
|
||||||
|
|
@ -52,7 +55,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
||||||
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
||||||
|
|
||||||
model = pcfg.preferred_models[0] if pcfg.preferred_models else 'default'
|
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
||||||
|
|
@ -78,7 +81,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
t = threading.Thread(target=_call_invoke, daemon=True)
|
t = threading.Thread(target=_call_invoke, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
t.join(timeout=10.0)
|
t.join(timeout=timeout)
|
||||||
|
|
||||||
el = round(time.time() - t0, 2)
|
el = round(time.time() - t0, 2)
|
||||||
|
|
||||||
|
|
@ -86,7 +89,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'duration_sec': el,
|
'duration_sec': el,
|
||||||
'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)',
|
'error': f'Превышено время ожидания ответа от провайдера ({timeout:g}с). Повторите проверку.',
|
||||||
}
|
}
|
||||||
|
|
||||||
if error_container:
|
if error_container:
|
||||||
|
|
@ -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_discovery_service import ModelDiscoveryService
|
||||||
from antigravity_provider.router.model_registry import ModelRegistry
|
from antigravity_provider.router.model_registry import ModelRegistry
|
||||||
|
|
||||||
discovered = ModelDiscoveryService.get().get_models(provider)
|
discovered = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id).get("models") or ModelDiscoveryService.get().get_models(provider)
|
||||||
if discovered is None:
|
if discovered is None:
|
||||||
try:
|
try:
|
||||||
discovered = ModelDiscoveryService.get().discover_models_sync(provider, timeout=5.0)
|
discovered = ModelDiscoveryService.get().discover_models_sync(provider, timeout=5.0)
|
||||||
|
|
@ -343,6 +346,8 @@ def _rescan_after_auth() -> None:
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
HubStateStore.get().refresh(force_scan=True)
|
HubStateStore.get().refresh(force_scan=True)
|
||||||
|
from .account_probe_service import AccountProbeService
|
||||||
|
AccountProbeService.get().schedule_all()
|
||||||
except Exception as exc: # пересбор не должен ронять сам вход
|
except Exception as exc: # пересбор не должен ронять сам вход
|
||||||
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
|
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
|
||||||
|
|
||||||
|
|
@ -539,8 +544,30 @@ def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]:
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
"""Shared execution layer for Desktop and Web actions."""
|
"""Shared execution layer for Desktop and Web actions."""
|
||||||
|
|
||||||
|
_connect_lock = threading.Lock()
|
||||||
|
_pending_connections: Dict[str, str] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
|
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
|
||||||
|
if action != "add_account":
|
||||||
|
return cls._execute(action, data, async_runner, actor)
|
||||||
|
import hashlib
|
||||||
|
from .account_probe_service import AccountProbeService
|
||||||
|
fingerprint = hashlib.sha256(json.dumps([
|
||||||
|
data.get("provider"), data.get("token") or data.get("api_key"), data.get("base_url")
|
||||||
|
]).encode()).hexdigest()
|
||||||
|
with cls._connect_lock:
|
||||||
|
cls._pending_connections = {key: pid for key, pid in cls._pending_connections.items() if AccountProbeService.get().state(pid).get("state") == "checking"}
|
||||||
|
previous = cls._pending_connections.get(fingerprint)
|
||||||
|
if previous and AccountProbeService.get().state(previous).get("state") == "checking":
|
||||||
|
return {"ok": False, "message": f"Аккаунт {previous} уже сохранён и проверяется. Дождитесь результата."}
|
||||||
|
result = cls._execute(action, data, async_runner, actor)
|
||||||
|
if result.get("ok"):
|
||||||
|
cls._pending_connections[fingerprint] = result["data"]["profile_id"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Execute the specified action.
|
Execute the specified action.
|
||||||
If async_runner is provided, long actions will be dispatched to it.
|
If async_runner is provided, long actions will be dispatched to it.
|
||||||
|
|
@ -608,6 +635,9 @@ class ActionExecutor:
|
||||||
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
|
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
|
||||||
if not slot:
|
if not slot:
|
||||||
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, slot)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason}
|
||||||
try:
|
try:
|
||||||
if provider == 'grok':
|
if provider == 'grok':
|
||||||
from antigravity_provider.router.grok_oauth import start_grok_oauth
|
from antigravity_provider.router.grok_oauth import start_grok_oauth
|
||||||
|
|
@ -703,7 +733,9 @@ class ActionExecutor:
|
||||||
else:
|
else:
|
||||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||||
|
|
||||||
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
|
slot = slot or AutoAssigner.find_free_slot(prov_norm)
|
||||||
|
if not slot:
|
||||||
|
return {'ok': False, 'message': 'Нет свободного слота'}
|
||||||
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
|
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
|
||||||
if not ok:
|
if not ok:
|
||||||
return {'ok': False, 'message': def_msg}
|
return {'ok': False, 'message': def_msg}
|
||||||
|
|
@ -722,7 +754,10 @@ class ActionExecutor:
|
||||||
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
||||||
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||||
_rescan_after_auth()
|
_rescan_after_auth()
|
||||||
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'}
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
AccountProbeService.get().schedule(prov_norm, slot, force=True)
|
||||||
|
check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена'
|
||||||
|
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
||||||
|
|
||||||
|
|
@ -746,6 +781,9 @@ class ActionExecutor:
|
||||||
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
|
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
|
||||||
if not slot:
|
if not slot:
|
||||||
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, slot)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason}
|
||||||
try:
|
try:
|
||||||
if provider == 'antigravity':
|
if provider == 'antigravity':
|
||||||
from antigravity_provider.router.profile_oauth import (
|
from antigravity_provider.router.profile_oauth import (
|
||||||
|
|
@ -863,8 +901,7 @@ class ActionExecutor:
|
||||||
|
|
||||||
elif action == 'test':
|
elif action == 'test':
|
||||||
if async_runner:
|
if async_runner:
|
||||||
async_runner(lambda: do_test_profile(prov, pid), 'TestProfile')
|
return cls._execute('check_account', {'provider': prov, 'profile_id': pid}, async_runner, actor)
|
||||||
return {'ok': True, 'message': 'запущено'}
|
|
||||||
else:
|
else:
|
||||||
res = do_test_profile(prov, pid)
|
res = do_test_profile(prov, pid)
|
||||||
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
||||||
|
|
@ -937,6 +974,23 @@ class ActionExecutor:
|
||||||
elif action == 'refresh_data':
|
elif action == 'refresh_data':
|
||||||
return {'ok': True, 'message': 'Обновление данных'}
|
return {'ok': True, 'message': 'Обновление данных'}
|
||||||
|
|
||||||
|
elif action == 'check_account':
|
||||||
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
if not AccountProbeService.get().enabled:
|
||||||
|
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
|
||||||
|
valid, reason = AutoAssigner.validate_slot(prov, pid)
|
||||||
|
if not valid:
|
||||||
|
return {'ok': False, 'message': reason}
|
||||||
|
started = AccountProbeService.get().schedule(prov, pid, force=True)
|
||||||
|
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
|
||||||
|
|
||||||
|
elif action == 'check_all_accounts':
|
||||||
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
if not AccountProbeService.get().enabled:
|
||||||
|
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
|
||||||
|
count = AccountProbeService.get().schedule_all(force=True)
|
||||||
|
return {'ok': True, 'message': f'Запущена проверка {count} аккаунтов'}
|
||||||
|
|
||||||
elif action == 'refresh_all':
|
elif action == 'refresh_all':
|
||||||
if async_runner:
|
if async_runner:
|
||||||
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')
|
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ DEFAULT_SLOT_ROLES = {
|
||||||
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
||||||
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
||||||
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
||||||
"local-1": ("Локальный сервер 1", "coder", "primary"),
|
"local-1": ("llama.cpp 1", "coder", "primary"),
|
||||||
"local-2": ("Локальный сервер 2", "fast", "primary"),
|
"local-2": ("llama.cpp 2", "fast", "primary"),
|
||||||
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
|
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
|
||||||
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
|
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
|
||||||
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
||||||
|
|
@ -80,7 +80,7 @@ class AutoAssigner:
|
||||||
"opengo": "OpenCode",
|
"opengo": "OpenCode",
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
"grok": "Grok",
|
"grok": "Grok",
|
||||||
"local": "Локальный сервер",
|
"local": "llama.cpp",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"nvidia": "NVIDIA NIM",
|
"nvidia": "NVIDIA NIM",
|
||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
|
|
@ -181,6 +181,34 @@ class AutoAssigner:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_slot(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||||
|
"""Reject foreign and unsafe slots before any auth/config mutation."""
|
||||||
|
import re
|
||||||
|
aliases = {
|
||||||
|
"antigravity": "ag", "google-antigravity": "ag", "agy": "ag",
|
||||||
|
"openai-codex": "codex", "codex": "codex", "openai": "codex",
|
||||||
|
"opencode-go": "opengo", "opencode": "opengo",
|
||||||
|
"anthropic": "claude", "claude": "claude", "xai": "grok", "grok": "grok",
|
||||||
|
"local": "local", "local-llm": "local", "llama.cpp": "local",
|
||||||
|
"nvidia-nim": "nvidia", "nvidia": "nvidia",
|
||||||
|
"openrouter": "openrouter", "ollama": "ollama", "vllm": "vllm",
|
||||||
|
}
|
||||||
|
owner = aliases.get(provider.strip().lower())
|
||||||
|
if not owner or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", profile_id):
|
||||||
|
return False, "Недопустимый провайдер или идентификатор слота"
|
||||||
|
reserved = profile_id.split("-", 1)[0]
|
||||||
|
if reserved in set(aliases.values()) and reserved != owner:
|
||||||
|
return False, f"Слот {profile_id} не принадлежит провайдеру {provider}"
|
||||||
|
existing = load_router_config().get_profile(profile_id)
|
||||||
|
if existing:
|
||||||
|
valid = aliases.get(existing.provider.lower()) == owner
|
||||||
|
else:
|
||||||
|
valid = profile_id.startswith(owner + "-")
|
||||||
|
if not valid:
|
||||||
|
return False, f"Слот {profile_id} не принадлежит провайдеру {provider}"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
|
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||||
"""Persist a router profile for provider slots introduced by the UI.
|
"""Persist a router profile for provider slots introduced by the UI.
|
||||||
|
|
@ -189,6 +217,9 @@ class AutoAssigner:
|
||||||
If discovery has not run yet, preferred_models remains empty [] instead
|
If discovery has not run yet, preferred_models remains empty [] instead
|
||||||
of inventing unsupported model literals.
|
of inventing unsupported model literals.
|
||||||
"""
|
"""
|
||||||
|
valid, reason = AutoAssigner.validate_slot(provider, profile_id)
|
||||||
|
if not valid:
|
||||||
|
return False, reason
|
||||||
config = load_router_config()
|
config = load_router_config()
|
||||||
if profile_id in config.profiles:
|
if profile_id in config.profiles:
|
||||||
return True, "Профиль уже зарегистрирован"
|
return True, "Профиль уже зарегистрирован"
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class ModelDiscoveryService:
|
||||||
self._cache_path = cache_path
|
self._cache_path = cache_path
|
||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._probe_context = threading.local()
|
||||||
self._ttl_seconds: int = 3600 # 1 hour
|
self._ttl_seconds: int = 3600 # 1 hour
|
||||||
self._load_cache_from_disk()
|
self._load_cache_from_disk()
|
||||||
|
|
||||||
|
|
@ -82,10 +83,11 @@ class ModelDiscoveryService:
|
||||||
return None
|
return None
|
||||||
return list(meta["models"])
|
return list(meta["models"])
|
||||||
|
|
||||||
def get_models_with_metadata(self, provider: str) -> Dict[str, Any]:
|
def get_models_with_metadata(self, provider: str, profile_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Return cached models and freshness status without blocking."""
|
"""Return cached models and freshness status without blocking."""
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower())
|
key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower()
|
||||||
|
entry = self._cache.get(key)
|
||||||
if not entry or "models" not in entry:
|
if not entry or "models" not in entry:
|
||||||
return {
|
return {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
|
@ -101,10 +103,10 @@ class ModelDiscoveryService:
|
||||||
models = entry.get("models")
|
models = entry.get("models")
|
||||||
return {
|
return {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"models": list(models) if models else None,
|
"models": list(models) if models is not None else None,
|
||||||
"discovered_at": discovered_at,
|
"discovered_at": discovered_at,
|
||||||
"is_stale": is_stale,
|
"is_stale": is_stale,
|
||||||
"has_cache": bool(models),
|
"has_cache": models is not None,
|
||||||
"error": entry.get("error"),
|
"error": entry.get("error"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,13 +190,15 @@ class ModelDiscoveryService:
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
|
||||||
def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]:
|
def discover_models_sync(self, provider: str, timeout: float = 15.0, profile_id: Optional[str] = None) -> Optional[List[str]]:
|
||||||
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
|
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
|
||||||
|
cache_key = f"{provider.lower()}:{profile_id}" if profile_id else provider.lower()
|
||||||
result_holder: List[Optional[List[str]]] = [None]
|
result_holder: List[Optional[List[str]]] = [None]
|
||||||
error_holder: List[Optional[str]] = [None]
|
error_holder: List[Optional[str]] = [None]
|
||||||
|
|
||||||
def _do_probe():
|
def _do_probe():
|
||||||
try:
|
try:
|
||||||
|
self._probe_context.profile_id = profile_id
|
||||||
models, err_msg = self._probe_provider(provider)
|
models, err_msg = self._probe_provider(provider)
|
||||||
result_holder[0] = models
|
result_holder[0] = models
|
||||||
error_holder[0] = err_msg
|
error_holder[0] = err_msg
|
||||||
|
|
@ -209,9 +213,9 @@ class ModelDiscoveryService:
|
||||||
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
|
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
|
||||||
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
|
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower(), {})
|
entry = self._cache.get(cache_key, {})
|
||||||
existing_models = entry.get("models")
|
existing_models = entry.get("models")
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": existing_models,
|
"models": existing_models,
|
||||||
"discovered_at": entry.get("discovered_at"),
|
"discovered_at": entry.get("discovered_at"),
|
||||||
"error": timeout_msg,
|
"error": timeout_msg,
|
||||||
|
|
@ -222,9 +226,9 @@ class ModelDiscoveryService:
|
||||||
models = result_holder[0]
|
models = result_holder[0]
|
||||||
err_text = error_holder[0]
|
err_text = error_holder[0]
|
||||||
|
|
||||||
if models:
|
if models is not None and not err_text:
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": models,
|
"models": models,
|
||||||
"discovered_at": time.time(),
|
"discovered_at": time.time(),
|
||||||
"error": None,
|
"error": None,
|
||||||
|
|
@ -236,9 +240,9 @@ class ModelDiscoveryService:
|
||||||
if err_text:
|
if err_text:
|
||||||
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
|
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower(), {})
|
entry = self._cache.get(cache_key, {})
|
||||||
existing_models = entry.get("models")
|
existing_models = entry.get("models")
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": existing_models,
|
"models": existing_models,
|
||||||
"discovered_at": entry.get("discovered_at"),
|
"discovered_at": entry.get("discovered_at"),
|
||||||
"error": err_text,
|
"error": err_text,
|
||||||
|
|
@ -247,9 +251,9 @@ class ModelDiscoveryService:
|
||||||
return list(existing_models) if existing_models else None
|
return list(existing_models) if existing_models else None
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
entry = self._cache.get(provider.lower(), {})
|
entry = self._cache.get(cache_key, {})
|
||||||
existing_models = entry.get("models")
|
existing_models = entry.get("models")
|
||||||
self._cache[provider.lower()] = {
|
self._cache[cache_key] = {
|
||||||
"models": existing_models,
|
"models": existing_models,
|
||||||
"discovered_at": entry.get("discovered_at"),
|
"discovered_at": entry.get("discovered_at"),
|
||||||
"error": entry.get("error") or "Модели не найдены",
|
"error": entry.get("error") or "Модели не найдены",
|
||||||
|
|
@ -257,9 +261,37 @@ class ModelDiscoveryService:
|
||||||
self._save_cache_to_disk()
|
self._save_cache_to_disk()
|
||||||
return list(existing_models) if existing_models else None
|
return list(existing_models) if existing_models else None
|
||||||
|
|
||||||
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
|
def discover_ollama_cloud(self) -> Dict[str, Any]:
|
||||||
|
"""Public catalog documented at https://docs.ollama.com/cloud#listing-models.
|
||||||
|
|
||||||
|
Catalog presence is not proof of an account's inference entitlement.
|
||||||
|
"""
|
||||||
|
key = "ollama-cloud-catalog"
|
||||||
|
error = None
|
||||||
|
models = None
|
||||||
try:
|
try:
|
||||||
raw_err = http_err.read().decode("utf-8", errors="replace")
|
req = urllib.request.Request("https://ollama.com/api/tags", headers={"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
models = sorted({str(m.get("name") or m.get("model")) for m in data.get("models", []) if isinstance(m, dict) and (m.get("name") or m.get("model"))})
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
error = self._extract_http_error(exc)
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)
|
||||||
|
with self._cache_lock:
|
||||||
|
previous = self._cache.get(key, {})
|
||||||
|
self._cache[key] = {
|
||||||
|
"models": models if models is not None else previous.get("models"),
|
||||||
|
"discovered_at": time.time() if models is not None else previous.get("discovered_at"),
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
self._save_cache_to_disk()
|
||||||
|
return self.get_models_with_metadata(key)
|
||||||
|
|
||||||
|
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
|
||||||
|
raw_err = ""
|
||||||
|
try:
|
||||||
|
raw_err = http_err.read().decode("utf-8", errors="replace")[:2000]
|
||||||
err_json = json.loads(raw_err)
|
err_json = json.loads(raw_err)
|
||||||
if isinstance(err_json, dict):
|
if isinstance(err_json, dict):
|
||||||
if "error" in err_json:
|
if "error" in err_json:
|
||||||
|
|
@ -278,12 +310,16 @@ class ModelDiscoveryService:
|
||||||
msg = raw_err
|
msg = raw_err
|
||||||
return f"HTTP {http_err.code}: {msg}"
|
return f"HTTP {http_err.code}: {msg}"
|
||||||
except Exception:
|
except Exception:
|
||||||
return f"HTTP {http_err.code}: {http_err.reason}"
|
return f"HTTP {http_err.code}: {raw_err or http_err.reason}"
|
||||||
|
|
||||||
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
|
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
|
||||||
from antigravity_provider.router.router_config import load_router_config
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
cfg = load_router_config()
|
cfg = load_router_config()
|
||||||
p_lower = prov.lower()
|
p_lower = prov.lower()
|
||||||
|
requested = getattr(self._probe_context, "profile_id", None)
|
||||||
|
if requested:
|
||||||
|
pcfg = cfg.get_profile(requested)
|
||||||
|
return [(requested, pcfg)] if pcfg else []
|
||||||
matched = [
|
matched = [
|
||||||
(pid, pcfg)
|
(pid, pcfg)
|
||||||
for pid, pcfg in cfg.profiles.items()
|
for pid, pcfg in cfg.profiles.items()
|
||||||
|
|
@ -326,7 +362,7 @@ class ModelDiscoveryService:
|
||||||
|
|
||||||
if prov in ("antigravity", "google-antigravity"):
|
if prov in ("antigravity", "google-antigravity"):
|
||||||
from antigravity_provider.agy_subprocess import discover_models
|
from antigravity_provider.agy_subprocess import discover_models
|
||||||
main_p = ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
|
main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
|
||||||
try:
|
try:
|
||||||
res = discover_models(profile_id=main_p)
|
res = discover_models(profile_id=main_p)
|
||||||
if res:
|
if res:
|
||||||
|
|
@ -614,7 +650,6 @@ class ModelDiscoveryService:
|
||||||
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
|
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
|
||||||
if name:
|
if name:
|
||||||
models.append(str(name))
|
models.append(str(name))
|
||||||
if models:
|
|
||||||
return sorted(set(models)), None
|
return sorted(set(models)), None
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
|
|
@ -635,7 +670,6 @@ class ModelDiscoveryService:
|
||||||
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
|
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
|
||||||
if mid:
|
if mid:
|
||||||
models.append(str(mid))
|
models.append(str(mid))
|
||||||
if models:
|
|
||||||
return sorted(set(models)), None
|
return sorted(set(models)), None
|
||||||
except urllib.error.HTTPError as http_err:
|
except urllib.error.HTTPError as http_err:
|
||||||
last_err = self._extract_http_error(http_err)
|
last_err = self._extract_http_error(http_err)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"auto_update": True,
|
"auto_update": True,
|
||||||
"release_channel": "stable",
|
"release_channel": "stable",
|
||||||
"model_timeout_seconds": 60,
|
"model_timeout_seconds": 60,
|
||||||
|
"account_check_interval_seconds": 300,
|
||||||
"monitoring_interval_seconds": 30,
|
"monitoring_interval_seconds": 30,
|
||||||
"quota_threshold_percent": 10.0,
|
"quota_threshold_percent": 10.0,
|
||||||
"quota_threshold_action": "notify",
|
"quota_threshold_action": "notify",
|
||||||
|
|
@ -81,6 +82,11 @@ def get_hub_settings() -> Dict[str, Any]:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
merged["account_check_interval_seconds"] = max(60, int(merged.get("account_check_interval_seconds", 300)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
merged["account_check_interval_seconds"] = 300
|
||||||
|
|
||||||
# Normalize numeric types
|
# Normalize numeric types
|
||||||
try:
|
try:
|
||||||
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))
|
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ class ProfileViewModel:
|
||||||
plan_source: str = "unknown"
|
plan_source: str = "unknown"
|
||||||
quota_snapshot: Optional[Any] = None
|
quota_snapshot: Optional[Any] = None
|
||||||
preferred_models: List[str] = field(default_factory=list)
|
preferred_models: List[str] = field(default_factory=list)
|
||||||
|
connection_check: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
model_discovery: Dict[str, Any] = field(default_factory=dict)
|
||||||
active_leases: int = 0
|
active_leases: int = 0
|
||||||
request_options: dict[str, Any] = field(default_factory=dict)
|
request_options: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
@ -518,15 +520,30 @@ class UnifiedHealthService:
|
||||||
"nvidia": "NVIDIA NIM",
|
"nvidia": "NVIDIA NIM",
|
||||||
"nvidia-nim": "NVIDIA NIM",
|
"nvidia-nim": "NVIDIA NIM",
|
||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"local": "Local LLM",
|
"local": "llama.cpp",
|
||||||
"local-llm": "Local LLM",
|
"local-llm": "llama.cpp",
|
||||||
"llama.cpp": "Local LLM (llama.cpp)",
|
"llama.cpp": "llama.cpp",
|
||||||
"vllm": "vLLM",
|
"vllm": "vLLM",
|
||||||
}.get(prov.lower(), prov)
|
}.get(prov.lower(), prov)
|
||||||
|
|
||||||
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
|
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
|
||||||
|
|
||||||
|
from .account_probe_service import AccountProbeService
|
||||||
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
check = AccountProbeService.get().state(pid)
|
||||||
|
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
|
||||||
|
if prov == "ollama":
|
||||||
|
model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
|
||||||
|
if is_authenticated and pcfg.enabled:
|
||||||
|
if check.get("state") == "checking":
|
||||||
|
health_state, health_lbl = "checking", "Проверяется…"
|
||||||
|
elif check.get("state") == "failed":
|
||||||
|
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д")
|
||||||
|
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
|
||||||
|
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
|
||||||
vm = ProfileViewModel(
|
vm = ProfileViewModel(
|
||||||
|
connection_check=check,
|
||||||
|
model_discovery=model_meta,
|
||||||
profile_id=pid,
|
profile_id=pid,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
account_identity=ident.primary_identifier() if is_authenticated else identity,
|
account_identity=ident.primary_identifier() if is_authenticated else identity,
|
||||||
|
|
@ -541,7 +558,7 @@ class UnifiedHealthService:
|
||||||
health_label_ru=health_lbl,
|
health_label_ru=health_lbl,
|
||||||
model_states=model_states,
|
model_states=model_states,
|
||||||
cooldown_remaining_sec=max_cd,
|
cooldown_remaining_sec=max_cd,
|
||||||
last_checked_at=now_str,
|
last_checked_at=datetime.datetime.fromtimestamp(check["checked_at"]).isoformat() if check.get("checked_at") else None,
|
||||||
last_success_at=last_success_str,
|
last_success_at=last_success_str,
|
||||||
enabled=pcfg.enabled,
|
enabled=pcfg.enabled,
|
||||||
is_cold_spare=is_cold,
|
is_cold_spare=is_cold,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
|
|
@ -225,7 +226,7 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
|
||||||
threading.Thread(target=func, name=name, daemon=True).start()
|
threading.Thread(target=func, name=name, daemon=True).start()
|
||||||
|
|
||||||
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
||||||
result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner, actor=actor)
|
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
|
||||||
if result.get("unknown"):
|
if result.get("unknown"):
|
||||||
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
||||||
|
|
||||||
|
|
@ -491,6 +492,9 @@ _SNAPSHOT_REFRESH_SEC = 30
|
||||||
|
|
||||||
def _background_refresh_loop() -> None:
|
def _background_refresh_loop() -> None:
|
||||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
AccountProbeService.get().enabled = True
|
||||||
|
AccountProbeService.get().tick()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
AccountQuotaService.get().fetch_all_configured(force=True)
|
AccountQuotaService.get().fetch_all_configured(force=True)
|
||||||
|
|
@ -507,6 +511,7 @@ def _background_refresh_loop() -> None:
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
AccountProbeService.get().tick()
|
||||||
HubStateStore.get().refresh(force_scan=False)
|
HubStateStore.get().refresh(force_scan=False)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Snapshot refresh failed: %s", exc)
|
logger.warning("Snapshot refresh failed: %s", exc)
|
||||||
|
|
|
||||||
|
|
@ -675,9 +675,11 @@ function renderAccountsView() {
|
||||||
'opencode-go': 'OpenCode Go',
|
'opencode-go': 'OpenCode Go',
|
||||||
claude: 'Claude (Anthropic)',
|
claude: 'Claude (Anthropic)',
|
||||||
grok: 'Grok (xAI)',
|
grok: 'Grok (xAI)',
|
||||||
local: 'Local LLM',
|
nvidia: 'NVIDIA NIM',
|
||||||
'local-llm': 'Local LLM',
|
openrouter: 'OpenRouter',
|
||||||
'llama.cpp': 'Local LLM (llama.cpp)',
|
local: 'llama.cpp',
|
||||||
|
'local-llm': 'llama.cpp',
|
||||||
|
'llama.cpp': 'llama.cpp',
|
||||||
ollama: 'Ollama',
|
ollama: 'Ollama',
|
||||||
vllm: 'vLLM',
|
vllm: 'vLLM',
|
||||||
};
|
};
|
||||||
|
|
@ -685,7 +687,7 @@ function renderAccountsView() {
|
||||||
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
const profilesByProv = currentSnapshot.profiles_by_provider || {};
|
||||||
let totalProfiles = 0;
|
let totalProfiles = 0;
|
||||||
let visibleProfiles = 0;
|
let visibleProfiles = 0;
|
||||||
let html = '';
|
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button></div>';
|
||||||
|
|
||||||
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
|
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
|
||||||
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
|
||||||
|
|
@ -733,7 +735,7 @@ function renderAccountsView() {
|
||||||
<div class="provider-group">
|
<div class="provider-group">
|
||||||
<div class="provider-group-header">
|
<div class="provider-group-header">
|
||||||
<div class="provider-group-title">
|
<div class="provider-group-title">
|
||||||
<span class="provider-dot" style="color: var(--prov-${providerId.replace('openai-', '').replace('-go', '')})">●</span>
|
<img class="brand-logo" src="/static/${getProviderIcon(providerId)}" width="24" height="24" alt="">
|
||||||
<span>${providerNames[providerId] || providerId}</span>
|
<span>${providerNames[providerId] || providerId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
|
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
|
||||||
|
|
@ -825,11 +827,34 @@ function renderAccountCard(profile) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
|
||||||
|
${renderAccountCheck(profile)}
|
||||||
${quotaGridHtml}
|
${quotaGridHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAccountCheck(profile) {
|
||||||
|
const check = profile.connection_check || {};
|
||||||
|
const meta = profile.model_discovery || {};
|
||||||
|
const checking = check.state === 'checking';
|
||||||
|
const models = meta.models || [];
|
||||||
|
const timestamp = meta.discovered_at ? new Date(meta.discovered_at * 1000).toLocaleString('ru-RU') : '';
|
||||||
|
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
|
||||||
|
return `<div class="account-check" aria-live="polite">
|
||||||
|
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
|
||||||
|
<p>${escapeHtml(modelStatus)}</p>
|
||||||
|
<div class="account-models">${models.slice(0, 8).map(modelBrandLabel).join('')}</div>
|
||||||
|
${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccountProbe(profileId) {
|
||||||
|
await executeAction('check_account', {profile_id: profileId});
|
||||||
|
await fetchSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
function renderQuotaCell(bucket, unavailableReason) {
|
function renderQuotaCell(bucket, unavailableReason) {
|
||||||
const remaining = bucket.remaining_percent;
|
const remaining = bucket.remaining_percent;
|
||||||
let formattedValue = 'Н/Д';
|
let formattedValue = 'Н/Д';
|
||||||
|
|
@ -1029,14 +1054,32 @@ function renderOverviewView() {
|
||||||
|
|
||||||
function getProviderIcon(provider) {
|
function getProviderIcon(provider) {
|
||||||
const map = {
|
const map = {
|
||||||
'openai-codex': 'codex.png',
|
'openai-codex':'openai.png', openai:'openai.png', codex:'openai.png',
|
||||||
'google-antigravity': 'антигравити.png',
|
antigravity:'antigravity.png', 'google-antigravity':'antigravity.png',
|
||||||
'opencode-go': 'opencode.png',
|
'opencode-go':'opencode.png', opencode:'opencode.png',
|
||||||
'anthropic-claude': 'claude.png',
|
claude:'claude-color.svg', 'anthropic-claude':'claude-color.svg', anthropic:'claude-color.svg',
|
||||||
'deepseek': 'deepseek.png',
|
grok:'grok.svg', xai:'grok.svg', ollama:'ollama.svg',
|
||||||
'grok': 'grok.jfif'
|
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(); }
|
function renderRoutingView() { renderAccountRouting(); }
|
||||||
|
|
@ -1493,6 +1536,7 @@ function renderSettingsView() {
|
||||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||||
|
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||||
|
|
||||||
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
||||||
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
||||||
|
|
@ -1503,6 +1547,17 @@ function renderSettingsView() {
|
||||||
if (emailMaskingSel && s.email_masking_mode) {
|
if (emailMaskingSel && s.email_masking_mode) {
|
||||||
emailMaskingSel.value = s.email_masking_mode;
|
emailMaskingSel.value = s.email_masking_mode;
|
||||||
}
|
}
|
||||||
|
if (accountIntervalInput && !accountIntervalInput.dataset.loaded) {
|
||||||
|
accountIntervalInput.dataset.loaded = 'loading';
|
||||||
|
fetch('/api/settings', {headers: authToken ? {'X-Hub-Token': authToken} : {}})
|
||||||
|
.then(response => { if (!response.ok) throw new Error('Настройки недоступны'); return response.json(); })
|
||||||
|
.then(settings => {
|
||||||
|
if (!Number.isFinite(Number(settings.account_check_interval_seconds))) throw new Error('Период не передан сервером');
|
||||||
|
accountIntervalInput.value = settings.account_check_interval_seconds;
|
||||||
|
accountIntervalInput.disabled = false;
|
||||||
|
accountIntervalInput.dataset.loaded = 'yes';
|
||||||
|
}).catch(error => { accountIntervalInput.placeholder = 'Н/Д: ' + error.message; accountIntervalInput.dataset.loaded = ''; });
|
||||||
|
}
|
||||||
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
|
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
|
||||||
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
||||||
}
|
}
|
||||||
|
|
@ -1519,8 +1574,10 @@ async function saveHubServerSettings() {
|
||||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||||
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
||||||
|
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||||
|
|
||||||
const newSettings = {};
|
const newSettings = {};
|
||||||
|
if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
|
||||||
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
|
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
|
||||||
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
|
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
|
||||||
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
||||||
|
|
@ -1699,7 +1756,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
||||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
||||||
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
|
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
|
||||||
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||||
const qs = profile.quota_snapshot;
|
const qs = profile.quota_snapshot;
|
||||||
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
||||||
|
|
@ -1721,13 +1778,15 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
|
||||||
modelBlockHtml = `
|
modelBlockHtml = `
|
||||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
|
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
|
||||||
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
||||||
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
|
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')">↻ Запросить список моделей</button>
|
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')" ${profile.connection_check?.state === 'checking' ? 'disabled' : ''}>${profile.connection_check?.state === 'checking' ? 'Запрашивается список моделей…' : '↻ Запросить список моделей'}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
|
||||||
|
|
||||||
// Local request options section
|
// Local request options section
|
||||||
let requestOptionsHtml = '';
|
let requestOptionsHtml = '';
|
||||||
if (profile.provider === 'local') {
|
if (profile.provider === 'local') {
|
||||||
|
|
@ -1782,6 +1841,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
|
||||||
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div id="modal-feedback-area"></div>
|
<div id="modal-feedback-area"></div>
|
||||||
|
|
@ -2293,7 +2353,7 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
|
||||||
|
|
||||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||||
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
||||||
const res = await executeAction('refresh_models', { provider: providerId });
|
const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' });
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
showToast('Запрос обновления моделей отправлен', 'success');
|
showToast('Запрос обновления моделей отправлен', 'success');
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
|
|
@ -2314,7 +2374,7 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
||||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
||||||
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
|
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
|
||||||
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||||
const qs = profile.quota_snapshot;
|
const qs = profile.quota_snapshot;
|
||||||
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
||||||
|
|
@ -2336,13 +2396,14 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
|
||||||
modelBlockHtml = `
|
modelBlockHtml = `
|
||||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
|
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
|
||||||
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
||||||
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
|
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')">↻ Запросить список моделей</button>
|
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')" ${profile.connection_check?.state === 'checking' ? 'disabled' : ''}>${profile.connection_check?.state === 'checking' ? 'Запрашивается список моделей…' : '↻ Запросить список моделей'}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
|
||||||
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div id="modal-feedback-area"></div>
|
<div id="modal-feedback-area"></div>
|
||||||
|
|
@ -2426,7 +2487,7 @@ function openAgentModelModal(roleId, profileId) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
|
|
||||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
||||||
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
|
const discoveredModels = profile.model_discovery?.models || ((provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []);
|
||||||
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||||
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
|
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
|
||||||
|
|
||||||
|
|
@ -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="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
|
||||||
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
||||||
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
|
${escapeHtml(profile.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')">↻ Запросить список моделей</button>
|
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')">↻ Запросить список моделей</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2501,10 +2562,10 @@ async function handleTestProfile(profileId) {
|
||||||
if (feedbackArea) {
|
if (feedbackArea) {
|
||||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
|
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
|
||||||
}
|
}
|
||||||
const res = await executeAction('test', { profile_id: profileId });
|
const res = await executeAction('check_account', { profile_id: profileId });
|
||||||
if (feedbackArea) {
|
if (feedbackArea) {
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Тест успешно пройден')}</div>`;
|
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Проверка запущена; результат появится в карточке')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
|
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -2526,6 +2587,10 @@ function openAddAccountWizard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWizardStep1() {
|
function showWizardStep1() {
|
||||||
|
stopDeviceAuthPolling();
|
||||||
|
stopRedirectAuthPolling();
|
||||||
|
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined;
|
||||||
|
window._wiz_provider = undefined;
|
||||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||||
|
|
@ -2603,6 +2668,12 @@ function showWizardStep1() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWizardStep2(providerId) {
|
function showWizardStep2(providerId) {
|
||||||
|
if (window._wiz_provider !== providerId) {
|
||||||
|
window._wiz_device_profile = undefined;
|
||||||
|
window._wiz_base_url = undefined;
|
||||||
|
window._wiz_token = undefined;
|
||||||
|
}
|
||||||
|
window._wiz_provider = providerId;
|
||||||
let bodyHtml = '';
|
let bodyHtml = '';
|
||||||
let footerHtml = '';
|
let footerHtml = '';
|
||||||
|
|
||||||
|
|
@ -2778,14 +2849,10 @@ function proceedToWizardStep3(providerId) {
|
||||||
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
|
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
|
||||||
if (isDeviceAuthFlow) {
|
if (isDeviceAuthFlow) {
|
||||||
const deviceSlot = document.getElementById('wiz-device-slot');
|
const deviceSlot = document.getElementById('wiz-device-slot');
|
||||||
if (deviceSlot && deviceSlot.value) {
|
window._wiz_device_profile = deviceSlot?.value || '';
|
||||||
window._wiz_device_profile = deviceSlot.value;
|
|
||||||
}
|
|
||||||
} else if (isRedirectAuthFlow) {
|
} else if (isRedirectAuthFlow) {
|
||||||
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
||||||
if (redirectSlot && redirectSlot.value) {
|
window._wiz_device_profile = redirectSlot?.value || '';
|
||||||
window._wiz_device_profile = redirectSlot.value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
|
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
|
||||||
showWizardStep3(providerId);
|
showWizardStep3(providerId);
|
||||||
|
|
@ -2837,6 +2904,10 @@ function showWizardStep3(providerId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finishAddAccount(providerId) {
|
async function finishAddAccount(providerId) {
|
||||||
|
if (window._wiz_saving) return;
|
||||||
|
window._wiz_saving = true;
|
||||||
|
const finishButton = elements.modalFooter?.querySelector(".btn-primary");
|
||||||
|
if (finishButton) finishButton.disabled = true;
|
||||||
const roleSelect = document.getElementById('wiz-target-role');
|
const roleSelect = document.getElementById('wiz-target-role');
|
||||||
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
|
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
|
||||||
|
|
||||||
|
|
@ -2857,7 +2928,7 @@ async function finishAddAccount(providerId) {
|
||||||
|
|
||||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||||
if (feedbackArea) {
|
if (feedbackArea) {
|
||||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
|
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохранение аккаунта и запуск проверки. Опрос провайдера может занять до минуты на этап.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -2873,10 +2944,12 @@ async function finishAddAccount(providerId) {
|
||||||
payload.token = window._wiz_token;
|
payload.token = window._wiz_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await executeAction('add_account', payload);
|
let res;
|
||||||
|
try { res = await executeAction('add_account', payload); }
|
||||||
|
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
|
||||||
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
|
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2914,6 +2987,7 @@ async function startDeviceAuth(providerId) {
|
||||||
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
|
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
|
||||||
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
|
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
|
||||||
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
|
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
|
||||||
|
if (window._wiz_provider !== providerId) return;
|
||||||
if (!res || !res.ok) {
|
if (!res || !res.ok) {
|
||||||
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
@ -2993,7 +3067,7 @@ function profilesInRouting() {
|
||||||
function buildSlotOptions(providerId) {
|
function buildSlotOptions(providerId) {
|
||||||
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
|
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
|
||||||
if (!profiles.length) {
|
if (!profiles.length) {
|
||||||
return '<option value="">Список слотов ещё не получен</option>';
|
return '<option value="">Новый свободный слот — автоматически</option>';
|
||||||
}
|
}
|
||||||
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
|
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
|
||||||
// ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый
|
// ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый
|
||||||
|
|
@ -3018,7 +3092,7 @@ function buildSlotOptions(providerId) {
|
||||||
else used.push(opt);
|
else used.push(opt);
|
||||||
});
|
});
|
||||||
// Свободные слоты с ролью — первыми: именно они дают работающий маршрут.
|
// Свободные слоты с ролью — первыми: именно они дают работающий маршрут.
|
||||||
return free.concat(used, idle).join('');
|
return '<option value="">Новый свободный слот — автоматически</option>' + free.concat(used, idle).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
let _redirectAuthTimer = null;
|
let _redirectAuthTimer = null;
|
||||||
|
|
@ -3056,6 +3130,7 @@ async function startRedirectAuth(providerId) {
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
profile_id: chosen || undefined,
|
profile_id: chosen || undefined,
|
||||||
});
|
});
|
||||||
|
if (window._wiz_provider !== providerId) return;
|
||||||
if (!res || !res.ok) {
|
if (!res || !res.ok) {
|
||||||
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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>
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 5.9 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
BIN
src/antigravity_provider/router/web/static/brands/openai.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/antigravity_provider/router/web/static/brands/opencode.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -432,6 +432,13 @@
|
||||||
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
|
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label class="setting-label" for="setting-account-check-interval">Проверка аккаунтов и моделей</label>
|
||||||
|
<div class="setting-desc">Каждые 300 секунд по умолчанию. Проверка отправляет короткий запрос модели и может расходовать квоту.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control"><input id="setting-account-check-interval" class="input-text" type="number" min="60" disabled placeholder="Н/Д: загрузка" aria-label="Интервал проверки аккаунтов, секунды"></div>
|
||||||
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<div class="setting-label">Интервал обновления квот</div>
|
<div class="setting-label">Интервал обновления квот</div>
|
||||||
|
|
|
||||||
|
|
@ -1823,9 +1823,14 @@ body[data-theme="medium"] .nav-icon { stroke:var(--canvas-ink); }
|
||||||
.account-summary strong { display:block; font-size:24px; font-weight:500; margin:6px 0; }
|
.account-summary strong { display:block; font-size:24px; font-weight:500; margin:6px 0; }
|
||||||
.account-summary span,.account-summary small { color:var(--text-muted); font-size:11px; }
|
.account-summary span,.account-summary small { color:var(--text-muted); font-size:11px; }
|
||||||
.accounts-groups-container { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
.accounts-groups-container { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||||
.provider-group,.accounts-grid { display:contents; }
|
.provider-group { grid-column:1 / -1; min-width:0; }
|
||||||
.provider-group-header { display:none; }
|
.provider-group-header { display:flex; justify-content:space-between; align-items:center; margin:12px 0; }
|
||||||
.account-card { min-width:0; padding:16px; }
|
.accounts-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr)); gap:12px; }
|
||||||
|
.account-card { min-width:0; padding:16px; height:auto; min-height:164px; gap:10px; }
|
||||||
|
.account-card-header { flex-wrap:wrap; }
|
||||||
|
.account-badges { min-width:0; flex-wrap:wrap; }
|
||||||
|
.account-card .badge-status { white-space:normal; overflow-wrap:anywhere; }
|
||||||
|
.badge-status.unhealthy { color:var(--status-error); }
|
||||||
.account-card-header { gap:8px; }
|
.account-card-header { gap:8px; }
|
||||||
.account-provider-tag { display:flex; align-items:center; gap:10px; }
|
.account-provider-tag { display:flex; align-items:center; gap:10px; }
|
||||||
.account-provider-tag img { width:30px; height:30px; object-fit:contain; }
|
.account-provider-tag img { width:30px; height:30px; object-fit:contain; }
|
||||||
|
|
@ -2059,3 +2064,15 @@ body[data-theme="medium"] .nav-item.active .nav-icon { stroke:var(--text-accent)
|
||||||
word-break: break-all;
|
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); }
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ function renderWorkflowNodes(workflow) {
|
||||||
const assignment = `${cfg.model || 'Модель: Н/Д'} • ${cfg.account || 'Аккаунт: Н/Д'}`;
|
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">
|
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>
|
<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>
|
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
|
||||||
</article>`;
|
</article>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ function renderAccountRouting() {
|
||||||
const profile = profiles[node.profile_id] || {profile_id:node.profile_id,provider:node.provider};
|
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 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('; ');
|
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('');
|
}).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>`;
|
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>';
|
}).join('') || '<p class="view-header-note">Агенты ещё не созданы. Добавьте агента на «Обзоре».</p>';
|
||||||
|
|
@ -200,7 +200,7 @@ function arrangeSettingsPanels() {
|
||||||
const first = view.querySelector('.settings-card');
|
const first = view.querySelector('.settings-card');
|
||||||
const groups = [
|
const groups = [
|
||||||
['Общие настройки',['setting-theme']],
|
['Общие настройки',['setting-theme']],
|
||||||
['Управление квотами',['setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
|
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
|
||||||
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
|
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
|
||||||
];
|
];
|
||||||
for (const [title,ids] of groups) {
|
for (const [title,ids] of groups) {
|
||||||
|
|
|
||||||
50
tests/manual/a50_preview.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Isolated UI preview with synthetic accounts and a loopback HTTP provider.
|
||||||
|
|
||||||
|
Run with PYTHONPATH=src python tests/manual/a50_preview.py. No owner data used.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
os.environ["HERMES_HOME"] = tempfile.mkdtemp(prefix="a50-ui-test-")
|
||||||
|
|
||||||
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.router_config import RouterConfig, save_router_config
|
||||||
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
from antigravity_provider.router.web import server
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureProvider(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"data":[{"id":"qwen3:fixture"}]}')
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
time.sleep(20)
|
||||||
|
healthy = "/working/" in self.path
|
||||||
|
self.send_response(200 if healthy else 401)
|
||||||
|
self.end_headers()
|
||||||
|
data = {"choices": [{"message": {"content": "fixture OK"}}]} if healthy else {"error": {"message": "Invalid API key — A50 test fixture"}}
|
||||||
|
self.wfile.write(json.dumps(data).encode())
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
http = ThreadingHTTPServer(("127.0.0.1", 5813), FixtureProvider)
|
||||||
|
threading.Thread(target=http.serve_forever, daemon=True).start()
|
||||||
|
save_router_config(RouterConfig())
|
||||||
|
for provider, pid, mode in [("nvidia", "nvidia-1", "failed"), ("nvidia", "nvidia-2", "working"), ("openrouter", "openrouter-1", "failed"), ("local", "local-1", "working")]:
|
||||||
|
AutoAssigner.ensure_profile_definition(provider, pid)
|
||||||
|
ProfileAuthManager.save_profile_auth(provider, pid, {"api_key": "intentionally-invalid-a50-fixture", "base_url": f"http://127.0.0.1:5813/{mode}/v1", "email": f"A50-TEST-{pid}"})
|
||||||
|
# No quota/OAuth/update network in this isolated UI fixture.
|
||||||
|
AccountQuotaService.get().fetch_all_configured = lambda **kw: None
|
||||||
|
AccountQuotaService.get().start_background_scheduler = lambda: None
|
||||||
|
server.UpdateManager.check_for_updates = lambda self: SimpleNamespace(error=None, message="Обновления отключены на тестовом стенде A50", update_available=False, to_dict=lambda: {})
|
||||||
|
server.run_web_server(host="127.0.0.1", port=5803)
|
||||||
|
|
@ -111,7 +111,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
assert "успешно подключен" in res["message"]
|
assert "сохранён" in res["message"]
|
||||||
|
|
||||||
# Verify profile created and auth saved
|
# Verify profile created and auth saved
|
||||||
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
||||||
|
|
@ -137,7 +137,7 @@ def test_p0_1_add_account_nvidia_default_base_url():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert res["ok"] is True
|
assert res["ok"] is True
|
||||||
assert "успешно подключен" in res["message"]
|
assert "сохранён" in res["message"]
|
||||||
|
|
||||||
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
||||||
assert auth is not None
|
assert auth is not None
|
||||||
|
|
|
||||||
199
tests/test_accounts_a50.py
Normal file
|
|
@ -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
|
|
@ -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');
|
||||||
|
|
@ -33,7 +33,8 @@ def test_check_memory_freshness_real_repo():
|
||||||
)
|
)
|
||||||
assert is_fresh is True
|
assert is_fresh is True
|
||||||
assert "FRESH" in summary
|
assert "FRESH" in summary
|
||||||
assert 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):
|
def test_check_memory_freshness_missing_file_strict_vs_non_strict(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,24 @@ async function runTests() {
|
||||||
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
|
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
|
||||||
assert(refreshModelsAction, 'handleRefreshProviderModels did not trigger refresh_models action');
|
assert(refreshModelsAction, 'handleRefreshProviderModels did not trigger refresh_models action');
|
||||||
|
|
||||||
|
console.log('10. A50: abandoned Antigravity -> NVIDIA/OpenRouter does not retain its slot');
|
||||||
|
for (const provider of ['nvidia', 'openrouter']) {
|
||||||
|
sandbox.openAddAccountWizard();
|
||||||
|
sandbox.showWizardStep2('antigravity');
|
||||||
|
getOrCreateElement('wiz-redirect-slot').value = 'ag-w1';
|
||||||
|
sandbox.proceedToWizardStep3('antigravity');
|
||||||
|
assert.strictEqual(sandbox.window._wiz_device_profile, 'ag-w1');
|
||||||
|
sandbox.showWizardStep1();
|
||||||
|
sandbox.showWizardStep2(provider);
|
||||||
|
getOrCreateElement('wiz-redirect-slot').value = '';
|
||||||
|
getOrCreateElement('wiz-token-input').value = 'intentionally-invalid';
|
||||||
|
sandbox.proceedToWizardStep3(provider);
|
||||||
|
await sandbox.finishAddAccount(provider);
|
||||||
|
const action = [...executedActions].reverse().find(a => a.action === 'add_account');
|
||||||
|
assert.strictEqual(action.data.provider, provider);
|
||||||
|
assert.strictEqual(action.data.profile_id, '');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\nAll targeted Node.js DOM and contract test assertions PASSED successfully!');
|
console.log('\nAll targeted Node.js DOM and contract test assertions PASSED successfully!');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||