Skip to content

dsh-rustvy

Verified

dsh-rustvy · v1.2.3 · MIT · Web UI

Rustvy for DeepSeek Harness: vendor marks in the model picker, a model list kept in step with what the provider actually serves, and a ring of API keys that lets each chat send its requests with a key of its own — the host half mirrors the catalog and log

Install

dsh plugin add dsh-rustvy

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

Регистрация на Rustvy по реферальной ссылке автора: https://rustvy.xyz/r/Z6LJC8FH При регистрации по этой ссылке вы получаете скидку 10% и бонусные токены. Подробные условия акции: https://rustvy.xyz/dashboard/referral

Rustvy (dsh-rustvy)

Русский | English

npm загрузки лицензия dsh-plugin

Значки производителей в списке моделей DeepSeek Harness, список моделей, который остаётся настолько же актуальным, насколько актуален сам провайдер, и несколько ключей провайдера в одном профиле — каждый чат отправляет запросы тем, который выбрал. Один плагин, одно зеркало, без перезапусков.

Что он делает

У плагина три задачи. Первые две работают от одного зеркала публичного каталога провайдера; третьей зеркало не нужно — ей нужен только маршрут профиля.

Значки. Один монохромный значок производителя перед каждой строкой модели в списке выбора — нарисованный так же, как его рисует собственная панель провайдера: CSS-маска, заливаемая currentColor, поэтому один файл на производителя обслуживает обе темы оформления и все состояния строки — наведение, выбор, отключение — без цветовых вариантов.

Где Что появляется
Меню моделей в композере монохромный значок 16 px перед каждой строкой модели
Кнопка модели в композере значок выбранной модели перед её названием (14 px)
Всплывающее окно команды /model тот же значок на каждой строке
Скины, перерисовывающие список (dsh-claude-style) тот же значок; собственная плашка скина в этой строке прячется, чтобы две плашки не встретились

Список моделей. Профиль подключается к стороннему провайдеру через вручную объявленный маршрут @deepseek-ai/dsh-llm-pi-ai, и именно список models этого маршрута определяет, что вообще можно выбрать в интерфейсе: harness предлагает модель только тогда, когда её отдаёт маршрут. Список, написанный вручную, устаревает в тот день, когда провайдер выпускает новую модель — новая невидима, а снятая с публикации остаётся выбираемой и падает уже на запросе. Плагин читает, что отдаёт собственный эндпоинт маршрута, превращает это в точный формат записей адаптера, показывает расхождение с установленным списком и по команде записывает его обратно через ctx.configEditor.

Ключи. Несколько ключей провайдера в одном профиле и свой ключ для отдельного чата — выбираемый в панели инструментов композера, сразу за списком режимов доступа. Ключ добавляется в самом плагине, кнопкой «+»: ни .env, ни cordis.patch.yml, ни страницу Models трогать не нужно, а выбор переживает перезапуск. Учётная запись DeepSeek не затрагивается: подмена касается только маршрутов этого плагина.

Где Что появляется
Настройки → Rustvy раздел «API-ключи»: список ключей, кнопка «+», замена значения, переименование, проверка, назначение ключом по умолчанию, удаление
Композер, сразу за списком режимов доступа компактный переключатель ключа этого чата, с меню и строкой «+ Добавить ключ»

Раздела ключей в меню моделей и в окне /model нет: выбор ключа живёт в одном месте. Меню моделей вместо этого не показывает те группы провайдеров, которые оказались клонами маршрутов этого кольца, — иначе один и тот же список моделей повторялся бы по разу на каждый ключ.

Зачем это нужно

Потому что обе поломки маршрута, который поддерживают вручную, — тихие. Модель, которую провайдер добавил, не появляется, и никто не говорит почему: ошибки нет, есть только отсутствие. Модель, которую провайдер снял, по-прежнему видна, и её выбор даёт сетевую ошибку, которая выглядит как проблема со связью, а не как устаревшая строка в YAML-файле.

И то и другое лечится чтением данных у провайдера вместо их переписывания. Список, который записывает этот плагин, несёт настоящие размер окна контекста, лимит вывода, входные модальности и уровни рассуждения, поэтому модель, получившая поддержку изображений или новый уровень мышления, описана верно в тот же день, а не наследует запасное значение, которое кто-то когда-то угадал.

А поскольку значки берутся из того же зеркала, совершенно новая модель приходит уже с кешированным значком своего производителя: зеркало перечитывает каталог в тот момент, когда эндпоинт называет модель, которой оно раньше не видело, — то есть ровно тогда, когда выходит релиз.

Третья задача — про другую тишину. Ключ провайдера — это отдельный лимит и отдельный учёт, а профиль один, поэтому «свой ключ на каждый чат» без плагина означает либо второй профиль, либо вторую строку в YAML, которую кто-то должен поддерживать вручную. Вручную поддержанный клон маршрута устаревает по тем же правилам, что и написанный вручную список моделей, — в день релиза. Кольцо ключей пишет клоны само и тем же проходом, которым записывается список моделей, поэтому маршрут ключа не отстаёт от маршрута профиля, а чат, отправляющий запросы другим ключом, видит ровно тот же выбор моделей, что и его сосед.

Ничего из этого не привязано к одному провайдеру. Сайт-источник, маршрут и строка профиля — настройки; https://rustvy.xyz и rustvy являются значениями по умолчанию.

Установка

dsh plugin --profile web add dsh-rustvy

Либо прямо с GitHub, если нужна самая свежая ревизия, а не опубликованная версия:

dsh plugin --profile web add github:koliy82/dsh-rustvy

Голый koliy82/dsh-rustvy без схемы не принимается: разбор источника установки знает имя в реестре, git-спецификацию, tarball и абсолютный локальный путь, а git-спецификации нужна схема либо сокращение github:.

Подтверждение сборки не запрашивается ни в том ни в другом случае: у пакета нет ни install-скрипта, ни зависимостей, поэтому pnpm нечего собирать.

Из локальной копии этого репозитория:

pwsh -File .\install.ps1

install.ps1 копирует пакет в <профиль>\node_modules\dsh-rustvy как настоящий каталог и дописывает имя в dsh.profile.bundles манифеста профиля. Работающий harness подхватывает строку по изменению манифеста.

После этого один раз перезапустите harness или обновите страницу: значки и страница настроек появляются на лету, а строка загрузчика читается на композиции.

Маршрут, в который пишется список моделей

Значки работают без всего остального. Половине со списком моделей нужен маршрут @deepseek-ai/dsh-llm-pi-ai, в который можно писать. Добавьте в cordis.patch.yml профиля — или отредактируйте в Настройки → Плагины → Конфигурация плагинов:

- id: llm-pi-ai
  name: "@deepseek-ai/dsh-llm-pi-ai"
  config:
    providers:
      rustvy:
        displayName: rustvy
        apiKeyEnv: RUSTVY_API_KEY
        api: openai-completions
        baseURL: https://rustvy.xyz/v1
        models:
          - id: placeholder
            name: placeholder

Список models обязан только разбираться: этот плагин его заменяет. Все остальные поля при перегенерации переносятся без изменений, потому что переключатель совместимости протокола или ссылка на учётные данные — это знание о вашем развёртывании, которого нет ни в одном каталоге.

apiKeyEnv задаёт, откуда маршрут берёт ключ; он разрешается на каждый запрос через механизм учётных данных harness — сначала унаследованное окружение процесса, затем $DSH_HOME/.credentials.yaml, затем .env в каталоге запуска или в $DSH_HOME. Сам ключ в конфигурационные файлы не попадает. Плагин при чтении списка моделей подставляет ключ только тогда, когда окружение процесса уже содержит переменную с таким именем; эндпоинт, под который он написан, отвечает и без ключа.

Чтобы сделать модель основной:

- id: agent-default-model
  name: "@deepseek-ai/dsh-agent-default-model"
  config:
    provider: rustvy
    model: claude-opus-5-5
    reasoningEffort: high

Чистый профиль: маршрут создаётся сам

Всё выше — про профиль, в котором маршрут уже есть. На чистом DSH его нет, и раньше плагин в этом случае не делал ничего и ничего не говорил: в списке моделей оставался встроенный DeepSeek, ключа от которого нет, то есть не работало ничего. Теперь хост-половина при монтировании проверяет, есть ли в строке llm-pi-ai провайдер rustvy, и если его нет — создаёт: адрес, имя, apiKeyEnv, форму API и список моделей, прочитанный у провайдера. Существующий маршрут не перезаписывается никогда и ни в одном поле: displayName, apiKeyEnv, baseURL и models могли быть исправлены руками, и это ваше знание о вашем развёртывании, которого нет ни в одном каталоге.

DeepSeek не затрагивается никогда. Он живёт в других строках композиции (llm-deepseek, llm-deepseek-account), а запись идёт только в строку llm-pi-ai, и в ней — только в ключ rustvy, с сохранением чужих провайдеров той же строки. Это закреплено тестом, который сравнивает строку llm-deepseek побайтово до и после, а не рассуждением о том, что запись туда попасть не должна: требование пользователя «deepseek вообще не трогал» абсолютное, и теперь, когда пишутся две строки вместо одной, оно важнее, а не менее важно.

Ключ при установке не спрашивается. Это намеренное решение: создание маршрута не должно ничего требовать, а маршрут без ключа — как раз то состояние, после которого список моделей появляется. Ключ добавляется потом, кольцом ключей на той же странице настроек или переменной RUSTVY_API_KEY. Поэтому нормальный вид сразу после установки — «маршрут создан, ключ ещё не задан», и страница говорит именно это.

Модель по умолчанию переключается, только если вы её сами не выбирали. Условия два и выполняются оба: маршрут отсутствовал и создан этим же монтированием, и у строки agent-default-model нет переопределения в слое профиля. Второе — единственная надёжная граница между чистой установкой и осознанным выбором: если модель когда-нибудь выбирали вы, строка тронута не будет и композер останется на вашем выборе. Если проверить выбор не удалось, плагин не пишет ничего — перезаписанную модель из него не восстановить, а догадка здесь дороже бездействия.

Что говорит страница настроек. Исход создания маршрута — условная строка вверху раздела Rustvy, над блоком аптайма, и рисуется она только когда есть что сказать:

Исход Строка
маршрут создан этим монтированием «Маршрут rustvy создан этим монтированием: N моделей» и отдельно про ключ
маршрут уже был ничего — кроме строки про ключ, если он не задан
создать не удалось «Маршрут rustvy не создан: …» с причиной и следующим действием
модель по умолчанию переключена «Модель по умолчанию переключена с X на Y — Z»
не переключена, потому что её выбирали вы одна спокойная строка с объяснением
не переключена, потому что маршрут уже был ничего

Порядок разделов при этом не меняется: строка условная, а решение о порядке касалось разделов. В установившемся состоянии не рисуется ничего, значит аптайм остаётся первым ровно как поставлен; когда строка рисуется, установка либо сломана, либо только что загружена, и это важнее порядка.

Кнопки на этой строке нет, и это решение, а не недоработка: запасной конец запускается только при монтировании, отдельной конечной точки повтора нет, поэтому кнопка могла бы лишь перечитать состояние, которое ничего не перезапускает, — получился бы элемент с подписью «повторить», который не повторяет. Настоящее действие — перезагрузка окна, и строка называет его текстом, по-разному для разных причин.

Про ключ страница утверждает ровно то, что знает. Хост спрашивает не окружение процесса, а тот же механизм учётных данных, которым пользуется адаптер, и учитывает кольцо: ключ, добавленный кольцом, делает провайдер рабочим, хотя RUSTVY_API_KEY в окружении пуста. «Ключ ещё не задан» печатается только когда не разрешается ни то ни другое; когда хост определить не смог, страница указывает оба места, ничего не утверждая. Две фразы различаются ровно одним утверждением, и проверка в наборе не даёт им слиться: слияние вернуло бы «ещё не задан» на машину, которая работает. Значение пересчитывается на каждый запрос состояния, поэтому ключ, добавленный кольцом после загрузки страницы, убирает строку без перезагрузки — иначе кольцо внизу показывало бы новый ключ, а строка наверху утверждала бы, что ключа нет.

Отдельно страница говорит, откуда взят список моделей, если источник не ответил и маршрут создан из локальной копии каталога: у такого списка неизвестная свежесть, и перечитать его стоит, когда сеть появится. Кнопка «Обновить каталог и иконки» здесь не лекарство — зеркало собирается с того же источника, и хост уже попытался обновить его сам, пока создавал маршрут.

Как пользоваться

Откройте Настройки → Rustvy. Страница показывает, что держит зеркало, что отдаёт провайдер и каково точное расхождение между ними. Кнопка «Обновить каталог и иконки» освежает обе половины, «Записать список моделей» записывает сгенерированный список. Без одного из этих нажатий не пишется ничего, если вы сами не попросили об этом в настройках.

Раздел «API-ключи» на той же странице держит кольцо: кнопка «+» открывает инлайн-форму «название + значение», у каждой строки ключа есть замена значения, переименование, назначение ключом по умолчанию, проверка и удаление. Ключом по умолчанию можно пользоваться, вообще не заходя в настройки: переключатель в панели инструментов композера делает то же самое для того чата, который открыт.

Настройки

Настройки → Rustvy

Сверху вниз страница устроена так: выключатели и поля, затем блок «Аптайм моделей», «Модели провайдера», «Ключи на несколько чатов», «Сбор ошибок Rustvy» и в самом низу «Зеркало каталога» с его адресами и файлами. Аптайм первый, потому что это единственный блок, отвечающий на вопрос, с которым страницу открывают, — жива ли модель, которую сейчас выберут; зеркало последнее, рядом с предупреждением о неудачном чтении каталога, которого только оно и касается.

Значки:

Параметр По умолчанию Смысл
Показывать иконки моделей вкл общий выключатель значков; в выключенном виде убирает все значки и не трогает список моделей
Иконка в кнопке выбранной модели вкл значок и в кнопке модели композера, а не только в строках меню; он же убирает с кнопки значок аптайма
Прятать логотипы других скинов вкл прятать собственную плашку скина в строке, где значок нарисован этим плагином
Показывать аптайм моделей вкл блок «Аптайм моделей» на этой странице и значок состояния в списке моделей и в кнопке выбранной модели; выключенный не читает зеркало коллектора вовсе
Размер значка, px 16 от 10 до 32

Список моделей:

Параметр По умолчанию Смысл
Держать список моделей маршрута актуальным вкл общий выключатель; в выключенном виде YAML маршрута не трогается
Записывать список при нажатии «Обновить» только отдельной кнопкой — самостоятельно не пишет никогда · при нажатии «Обновить» — пишет по кнопке обновления · сам, при каждом обновлении — пишет и при фоновом суточном обновлении
Название модели как на сайте провайдера отображаемое имя провайдера либо сырой идентификатор
Предлагать уровень «без рассуждений» вкл добавлять уровень off рассуждающей модели, которая его не объявила
Оставлять модели, которые провайдер больше не отдаёт выкл сохранять установленную модель, которую эндпоинт больше не отдаёт, — на случай временно ошибочного списка, а не действительно снятой модели
Маршрут провайдера rustvy какой ключ внутри providers записывать
Строка профиля llm-pi-ai какая строка загрузчика его содержит
Сайт провайдера https://rustvy.xyz откуда читать каталог и логотипы

Режим записи по умолчанию намеренно средний: нажатие кнопки — это решение человека, а фоновое истечение срока — нет, и переписывать конфигурацию загрузчика работающего профиля, когда никто не просил, — это то, на что стоит спросить разрешение.

Каждый адрес и файл, с которым работает плагин, виден на странице: сайт, страница каталога и эндпоинт списка моделей — ссылками, открывающимися в новой вкладке, а пути к зеркалу, настройкам, копии для отката и патчу профиля — текстом с кнопкой копирования. Кнопка «Показать блок для ручной правки» выдаёт ровно тот YAML, который получился бы при автоматической записи, так что желающий вставить его в cordis.patch.yml сам не переписывает краткий пересказ.

У одного поля нет элемента управления, потому что оно принадлежит YAML профиля, а не форме: modelZones (по умолчанию ["standard"]) — зоны каталога, которые допускаются, когда эндпоинт недоступен и состав списка определяет зеркало.

Ключи:

Параметр По умолчанию Смысл
Ключи на несколько чатов вкл общий выключатель кольца; в выключенном виде подмена маршрута не применяется и каждый запрос идёт маршрутом профиля

Выключение ничего не удаляет: маршруты ключей остаются в конфигурации профиля, значения — в хранилище учётных данных, выбор чатов — в своём файле, поэтому включённая обратно возможность продолжает с того места, на котором её выключили. Переключатель живёт в разделе «API-ключи», а не рядом с выключателями значков и списка моделей: он принадлежит кольцу, и там же страница объясняет, почему добавление ключа недоступно, если хост не отдаёт нужный сервис.

Сбор ошибок:

Параметр По умолчанию Смысл
Собирать ошибки rustvy вкл общий выключатель сбора: одна строка на инфраструктурный сбой провайдера уходит коллектору аптайма. Выключенный не отправляет ничего, и накопленная очередь остаётся на диске

Выключатель живёт в собственном блоке «Сбор ошибок Rustvy», потому что это единственная настройка страницы, которая что-то отправляет за пределы машины, — одна строка на сбой провайдера уходит коллектору монитора. Сам блок — заголовок и выключатель, больше ничего: ни пояснения под выключателем, ни счётчиков, ни кнопок отправки на странице нет. Что именно уходит, что не уходит никогда и когда сбор срабатывает, описано в разделе «Сбор ошибок и аптайм» ниже и в docs/error-report.md — раскрытие не исчезло вместе с текстом на странице, оно переехало туда, где его можно прочитать целиком. Состояние сбора — адрес коллектора, анонимный идентификатор, счётчики отправленного, очереди и отброшенного, последний ответ, последняя ошибка отправки и разбивка сбоев по видам — хост-половина по-прежнему отдаёт на своих петлевых маршрутах /dsh-rustvy/state и /dsh-rustvy/errors, и смотреть его нужно там, а не в интерфейсе. Адрес — uptimeEndpoint, по умолчанию http://185.75.189.247:3789/api/report — поля ввода не имеет: его перенаправляют без выпуска новой версии переменной окружения DSH_RUSTVY_UPTIME_ENDPOINT или правкой <профиль>\.dsh-rustvy\config.json руками.

Настройки хранятся в <профиль>\.dsh-rustvy\config.json. Пять переменных окружения перекрывают их — для развёртывания, которое направляет зеркало и сборщик в другое место либо хочет смонтировать плагин неработающим, молчащим или без панели аптайма: DSH_RUSTVY_ORIGIN и DSH_RUSTVY_DISABLED=1, DSH_RUSTVY_UPTIME_ENDPOINT, DSH_RUSTVY_NO_ERRORS=1 и DSH_RUSTVY_NO_UPTIME=1. Первая перенаправляет сайт провайдера, вторая выключает плагин целиком. Третья перенаправляет коллектор аптайма и переопределяет и значение по умолчанию, и config.json: принимается только адрес, разбирающийся как http/https, концевые слеши обрезаются, а непригодное значение игнорируется, и тогда остаётся действующая настройка. Четвёртая и пятая, каждая в значении 1 или true, выключают независимо от настройки сбор ошибок и вид аптайма — тот же приём, которым DSH_RUSTVY_DISABLED гасит значки.

Откуда берутся картинки

В сам пакет ничего не вшито. Хост-половина зеркалит два публичных объекта с сайта провайдера в профиль, а браузерная половина читает их с локального адреса:

  1. каталог моделей — сайт рисует свою таблицу моделей из JSON-острова в собственной странице (<script id="rustvy-catalog">), поэтому неаутентифицированный читатель получает ровно те данные, которые видит панель. Зеркалится в <профиль>\.dsh-rustvy\catalog.json.
  2. файлы логотипов (/logos/<имя>.svg) — копируются в <профиль>\.dsh-rustvy\logos\ при первом обращении.

Оба читаются по собственному исходящему маршруту процесса (lib/net.js): сначала живой глобальный диспетчер, чтобы прокси, установленный dsh-proxy-gui или любым другим маршрутом процесса, соблюдался, затем соединитель, построенный здесь из HTTPS_PROXY / ALL_PROXY / HTTP_PROXY, — на случай, когда диспетчер не установлен. NO_PROXY учитывается в обоих случаях. Сама страница наружу не выходит: браузер спрашивает значок у 127.0.0.1, и именно поэтому всё работает на машине, чей браузер до источника не достаёт вовсе.

Каталог обновляется раз в 24 часа и по требованию; холодное зеркало ожидается до 6 секунд, а устаревшее обновляется в фоне, пока строки продолжают показывать имеющиеся значки. Когда источник недоступен, кеш на диске по-прежнему отдаёт всё, что в нём есть.

Откуда берётся список моделей

Два источника в таком порядке, потому что отвечают они на разные вопросы:

  1. собственный эндпоинт маршрута (GET {baseURL}/models) — авторитетный список состава: ровно те модели, которые можно назвать в запросе, каждая со своими context_k, max_output_tokens, modalities и reasoning_levels. Это тот же эндпоинт, который опрашивает собственное обнаружение адаптера.
  2. зеркало острова каталога — названия и запасной вариант. Это единственный источник, несущий отображаемое имя, производителя и имя файла логотипа, поэтому он соединяется с отдаваемым списком по идентификатору. Когда эндпоинт не отвечает, состав определяет сам остров по своим полям enabled и zone, так что маршрут остаётся актуальным и во время сбоя, а не застывает на последнем удачном чтении. На здоровом источнике оба списка совпадают точно.

Модель, которую эндпоинт отдаёт, а зеркало ни разу не видело, — признак того, что остров отстал от боевого API, а это происходит ровно в момент релиза, поэтому тогда остров перечитывается; ожидание ограничено тем же коротким сроком, что и для холодного зеркала.

Что именно записывается

Каждая сгенерированная запись несёт только те поля, которые объявлены в схеме конфигурации адаптера, потому что список models профиля маршрута заменяет каталог маршрута, а неизвестное поле в лучшем случае будет отброшено:

Поле Откуда берётся
id идентификатор отдаваемой модели — то, что нужно назвать в запросе
name отображаемое имя из острова либо идентификатор, если имени нет
contextWindow context_k × 1000
maxTokens max_output_tokens
input modalities, отфильтрованные до text/image
reasoningEfforts reasoning_levels, отображённые на уровни адаптера, либо false для нерассуждающей модели
compat не генерируется никогда — существующее всегда переносится

Поле, о котором никто не высказался, пропускается, а не заполняется значением по умолчанию, чтобы запасное значение самого адаптера оставалось в силе и написанное вручную не закреплялось молча. compat — единственное, что переносится без изменений: переключатель совместимости протокола это знание о шлюзе, которого нет ни в одном каталоге.

Уровни рассуждения отображаются на словарь адаптера (off, minimal, low, medium, high, xhigh, max); неизвестный ему уровень отбрасывается и сообщается примечанием, а не отказом всей записи. Необъявленные уровни адаптер не поддерживает, а объявленный off без боевого значения означает «предлагать, ничего не отправляя» — поэтому этот переключатель включён по умолчанию.

Что запись отклоняет

Пустой список не записывается никогда: единственный способ его получить — неудачное чтение, а маршрут без моделей не обслуживает ничего. Актуальный список тоже не записывается, потому что configEditor.edit() согласует весь загрузчик, и тратить одно согласование на отсутствие изменений означало бы перезагрузить работающий профиль впустую. Актуальным при этом считается не только маршрут профиля: запись происходит и тогда, когда сам он в порядке, а список моделей одного из клонов кольца ключей отстал, — иначе чат, отправляющий запросы другим ключом, видел бы меньше моделей, чем его сосед. Каждая запись сохраняет предыдущий документ в <профиль>\.dsh-rustvy\models-backup.json, а кнопка «Откатить к предыдущему» возвращает его обратно; состояние, которое откат вытесняет, само становится следующей копией, поэтому откат тоже можно откатить.

Как строка сопоставляется со значком

Строки сопоставляются по видимому названию в таком порядке:

  1. идентификатор модели из зеркального каталога (claude-opus-5-5, abyss/glm-5.3, …);
  2. её отображаемое имя (OPUS 5.5);
  3. идентификатор, с которого название начинается, — скин может оставить в том же элементе собственную строку описания, поэтому название часто является префиксом, а не всей строкой; побеждает самый длинный подходящий идентификатор, чтобы claude-opus-5-5 никогда не достался claude-opus-5;
  4. форма идентификатора — для модели, которой зеркало ни разу не видело: claude-* → Anthropic, gpt-*/o1…o9 → OpenAI, glm-* → Zhipu, kimi-*/k3 → Moonshot, qwen-* → Alibaba Qwen, deepseek-* → DeepSeek, ahmia-* → Ahmia, mimo-* → Xiaomi, fable-* → Anthropic, gemini-* → Google, grok-* → xAI.

Модель, не подошедшая ни под что, не получает значка вовсе: выдуманная иконка была бы неправильным ответом, а не отсутствующим. Модель, которая подошла, но чей логотип зеркало получить не смогло, получает вместо пустого квадрата плашку с монограммой — первыми буквами производителя: маска с неработающим URL закрасилась бы сплошным квадратом.

Строки уровней усилия, заголовки производителей, корневые ячейки меню, поле поиска и строки о сбое каталога не подходят ни под одно правило и остаются без значка.

Кольцо API-ключей

Несколько ключей провайдера в одном профиле, и каждый чат отправляет запросы тем, который выбрал его пользователь. Выбор живёт в панели инструментов композера — рядом со списком режимов доступа, а не в меню моделей, — действует на один чат и переживает перезапуск. Учётная запись DeepSeek не затрагивается ни в одном из мест этого раздела.

Почему один ключ — это один маршрут

@deepseek-ai/dsh-llm-pi-ai берёт ключ из profile.apiKeyEnv — это имя, а не значение, — и разрешает его через механизм учётных данных harness на каждый запрос, ничего не кешируя. Поля для ключа нет ни в конфигурации вызова (LlmCallConfig), ни в параметрах генерации (GenerateOptions), а baseURL и заголовки запекаются в снапшот профиля. Передать адаптеру ключ «в этом запросе» нечем, поэтому единственный честный способ дать чату другой ключ — другой маршрут: клон с теми же api, baseURL и models и своим apiKeyEnv.

Из этого следует всё устройство. Добавление ключа пишет значение в хранилище учётных данных под новым именем, клонирует маршрут профиля через ctx.configEditor и запоминает запись в кольце. Выбор чата применяется на водопаде agent/request подменой имени маршрута в конфигурации вызова — тем же приёмом и на том же шве, которым harness применяет выбор модели для отдельного чата, поэтому он работает ровно там, где уже работает выбор модели, и не заводит собственного пути к запросу.

Ключ Имя учётных данных Маршрут Отображаемое имя маршрута
первый (основной) RUSTVY_API_KEY — как объявлен в профиле rustvy — как объявлен в профиле rustvy
второй RUSTVY_API_KEY_2 rustvy-k2 rustvy · <название ключа>
n-й RUSTVY_API_KEY_<n> rustvy-k<n> rustvy · <название ключа>

Имена строятся от маршрута из настроек, а не зашиты: при modelRoute: rustvy второй ключ получает rustvy-k2, при другом имени маршрута — свои суффиксы. Клон копирует профиль маршрута целиком, меняя только apiKeyEnv и отображаемое имя, поэтому написанный вручную переключатель совместимости протокола и всё остальное знание о своём шлюзе переходит в каждый ключ, а не теряется при первом же добавлении.

Предел — 12 ключей: это кольцо, а не реестр. Новый ключ берёт первый порядковый номер, у которого свободны и имя учётных данных, и маршрут, поэтому имя удалённого ключа не переиспользуется, а чужая строка, случайно оказавшаяся на таком имени, не перезаписывается.

Основной ключ

Первый ключ кольца — не запись, которую кто-то добавил, а маршрут, уже объявленный в профиле: он наследует его apiKeyEnv и его имя и основным остаётся всегда. Профиль, который работал до этой возможности, продолжает работать без изменений — кольцо с одним ключом не переименовывает ничего и никуда не пишет. Удалить основной ключ нельзя: он принадлежит маршруту профиля, и его удаление сломало бы провайдера для каждого чата, включая те, которые кольцом ни разу не воспользовались. Отказ так и объясняет и предлагает заменить значение — эта операция доступна любому ключу и конфигурации не касается, потому что имя учётных данных при ней не меняется.

Где что лежит

Что Где
Значения ключей $DSH_HOME\.credentials.yaml, раздел refs — то же хранилище, в которое пишет страница Models harness и из которого читает адаптер
Метаданные кольца <профиль>\.dsh-rustvy\keys.json — id, название, имя учётных данных, маршрут, признак основного, порядковый номер, дата создания и defaultKeyId
Выбор чатов <профиль>\.dsh-rustvy\session-keys.json — sessionId → keyId
Анонимный идентификатор установки <профиль>\.dsh-rustvy\install.json — случайный UUID и дата создания; принадлежит сбору ошибок, а не кольцу, и лежит рядом с остальными файлами плагина
Неотправленные отчёты об ошибках <профиль>\.dsh-rustvy\errors-queue.json — очередь отчётов, которые коллектор аптайма ещё не принял
Последний вид монитора <профиль>\.dsh-rustvy\uptime.json — кешированный ответ коллектора, который панель показывает с пометкой stale, когда коллектор недоступен; принадлежит виду аптайма, а не кольцу

В файлах плагина значения ключа нет никогда: ни в keys.json, ни в session-keys.json, ни в настройках, ни в копии профиля для отката, ни в очереди отчётов об ошибках. Loopback-маршруты отвечают маской — первые три и последние два символа — и восьмисимвольным отпечатком SHA-256; этого достаточно, чтобы узнать ключ в списке и заметить две записи с одним значением, и недостаточно, чтобы им воспользоваться. Само значение приходит в теле единственного запроса, который его добавляет или заменяет, и никогда в строке запроса: URL пишется в журналы, попадает в кеш и в историю браузера, а секрет — не дело ни того, ни другого, ни третьего.

Выбор хранится на диске, а не в журнале сессии: это предпочтение маршрутизации самого плагина, и плагин, пишущий собственные события в долговременный журнал, оставил бы их в каждой сессии, где когда-либо был смонтирован. В файле лежат только отклонения от ключа по умолчанию, поэтому чат, созданный до смены ключа по умолчанию, следует за новым, а не держится старого.

Что видно в интерфейсе

Две поверхности: одна управляет кольцом, другая выбирает ключ для своего чата. Переключатель появляется только тогда, когда выбирать есть из чего: при одном ключе его нет вовсе, потому что один ключ — это не выбор.

Настройки → Rustvy → «API-ключи». Список ключей: название, маска, имя учётных данных, маршрут, сколько моделей отдаёт этот маршрут и сколько чатов выбрали этот ключ. Кнопка «+» в заголовке раздела открывает инлайн-форму «название + значение» со скрытым полем и показом по желанию. У каждой строки — замена значения, переименование (сохраняется по потере фокуса), назначение ключом по умолчанию, проверка и удаление в два шага; у основного ключа кнопки удаления нет, и строка объясняет почему. Ниже — блок «Маршруты ключей»: строка профиля и пакет, адрес списка, счётчик «запросов через подмену», число чатов со своим выбором, списки отсутствующих и отставших маршрутов, пути к обоим файлам с кнопкой копирования и кнопка «Починить маршруты», которая появляется только тогда, когда маршрут какого-то ключа действительно пропал.

Композер. Компактная кнопка в панели инструментов, сразу за списком режимов доступа, — регистрация в слоте conversation.input.left: название активного ключа либо «ключ по умолчанию», если у чата нет собственного выбора. В меню — по строке на ключ с галочкой на активном, строка «По умолчанию» и строка «+ Добавить ключ» с той же инлайн-формой; ключ, добавленный из чата, сразу выбирается для этого чата, потому что добавляют его из разговора именно затем, чтобы отправлять запросы им. Кнопка не рисуется, пока слот не передал sessionId: выбор принадлежит чату, и переключатель, который сохранит его не тому чату, хуже отсутствия переключателя.

Меню выбора модели и всплывающее окно /model. Раздела ключей здесь нет — ни строк, ни заголовка: выбор живёт в композере, и второй такой же список в меню только удваивал бы место, где можно ошибиться. Декоратор делает здесь обратное — прячет группы провайдеров, которые хост назвал в hiddenGroups, то есть клоны маршрутов этого кольца, чтобы один и тот же список моделей не показывался по разу на каждый ключ. Не объявить их хост не может: клону нужен собственный список моделей, иначе после подмены маршрута запрос не найдёт в нём свою модель. Скрытие структурное и осторожное: заголовок группы опознаётся по форме, а не по имени класса, потому что классы хозяйских примитивов этому плагину не видны и между версиями не стабильны, а прячется либо наименьший содержащий группу элемент, либо заголовок вместе с идущими за ним строками до следующего заголовка. Если уверенности нет, не прячется ничего: по ошибке спрятанная строка модели — это отнятый выбор, а лишний раз показанная группа — только повтор.

Что происходит при добавлении ключа

Порядок операций намеренный. Сначала значение пишется в хранилище учётных данных: маршрут, чьё имя ни во что не разрешается, роняет каждый запрос, который его назвал. Затем идёт запись конфигурации, и если она не удалась, значение забирается обратно, поэтому неудачное добавление не оставляет после себя ничего. Документ кольца пишется последним, когда существуют и значение, и маршрут.

Запись конфигурации — настоящая и поддерживаемая: она идёт через ctx.configEditor, который согласует профиль, и ровно то же самое делает страница Models harness, когда в профиль добавляют провайдер. configEditor.edit() согласует весь загрузчик, поэтому записи кольца выстроены в очередь: два ключа, добавленные одновременно, — это две последовательные правки, а не две правки против документа, который одна другая только что заменила. И когда карта маршрутов уже соответствует кольцу, запись не происходит вовсе — тратить согласование на отсутствие изменений этот плагин отказывается и в списке моделей.

Ключ можно проверить, не отправляя им запрос: проверка делает GET {baseURL}/models с этим ключом и сообщает статус, число отданных моделей и текст ошибки провайдера как он пришёл.

Насколько узка подмена

Подмена меняет ровно одно поле конфигурации вызова — имя маршрута — и только когда выполнено всё перечисленное:

  1. возможность включена настройкой apiKeys;
  2. запрос пришёл с идентификатором сессии — без него выбирать не для кого;
  3. в кольце два ключа или больше;
  4. ключ этого чата — его собственный выбор, а при его отсутствии ключ по умолчанию — не основной;
  5. запрос и без того шёл одним из маршрутов этого кольца (rustvy, rustvy-k*);
  6. целевой маршрут установлен в профиле;
  7. целевой маршрут отдаёт запрашиваемую модель.

Во всех остальных случаях конфигурация возвращается как пришла. deepseek-account и любой другой провайдер — второй шлюз, соседняя строка, чужая запись с ключом — не переименовываются никогда: условие пятое проверяет принадлежность маршрута кольцу, а не его похожесть.

Клон, который пропал или не отдаёт нужную модель, не роняет шаг: запрос уходит маршрутом из профиля, а причина записывается. Она видна в поле fallback на GET /keys — страница настроек рисует её предупреждением над списком ключей — и строкой в журнале, которая не повторяется, пока причина не изменится. Логика здесь такая: неработающий маршрут ключа — это проблема конфигурации, и правильный ответ на неё — состоявшийся запрос плюс видимая причина, а не упавший шаг, о котором не сказано почему. Исключение внутри самой подмены перехватывается, и запрос идёт как шёл.

Список моделей пишется одним проходом на все маршруты кольца — базовый и каждый клон, который кольцо видит в профиле, — поэтому клон не может отстать от базового маршрута, а блок для ручной правки (/models.yaml) содержит клоны вместе с их именами учётных данных, чтобы желающий вставить строку в cordis.patch.yml сам не реконструировал их по описанию.

Два счётчика на GET /keys отвечают на вопрос, который иначе выглядел бы как «ключи не работают»: routing.seen — сколько запросов harness вообще провёл через водопад agent/request, routing.applied — сколько из них были переименованы на маршрут ключа. Ноль при живом трафике означает, что водопад не доходит до плагина, и это видно сразу.

Сбор ошибок и аптайм

Монитор существует потому, что «сайт провайдера лежит» — это знание, которое сегодня есть только у тех, кто в него упёрся, и у каждого из них по отдельности. Сбой запроса выглядит как локальная проблема: человек повторяет шаг, переключает модель или уходит, и никто — включая самого провайдера — не узнаёт, что в этот же момент upstream_502 ловили ещё четыреста установок.

Собирать это опросом нельзя. Проверочный запрос — это настоящий запрос к модели, он стоит токенов того, чьим ключом отправлен, и одна метка «живо» на каждой модели раз в минуту сжигает ровно то, за что пользователь платит. Поэтому аптайм здесь выводится из пассивных отчётов об ошибках: запрос, который и так состоялся, либо удался, либо сломался, хост-половине известен любой исход, но наружу уходит только сбой. Дополнительных запросов нет вовсе, а монитор судит о здоровье модели по отсутствию ошибок в окне — и знает то, чего опрос не покажет никогда: сколько людей задел сбой.

Что уходит в одном отчёте

Один отчёт — один запрос: плоский JSON-объект, без конверта и без массива событий. Коллектор принимает его на POST /api/report; meta — единственный объект в нём, а всё остальное лежит на верхнем уровне, потому что таких отчётов хранится много и читаются они обратно сгруппированными по модели и часу.

Адрес по умолчанию — http://185.75.189.247:3789/api/report: публичный коллектор монитора, пока намеренно на голом HTTP и по голому IP, — и он меняется без выпуска новой версии, полем uptimeEndpoint в <профиль>\.dsh-rustvy\config.json или переменной окружения DSH_RUSTVY_UPTIME_ENDPOINT.

Поле запроса Что несёт
plugin dsh-rustvy
version версия пакета
provider имя маршрута, которым шёл запрос
model идентификатор модели
code вид сбоя из собственного словаря плагина: upstream_502, connect_timeout, dns, …
status HTTP-статус, и только когда он есть: без соединения поля нет вовсе, а не 0 в нём
message собственный текст ошибки провайдера, очищенный и обрезанный до 2000 символов
ts момент сбоя в миллисекундах; коллектор в любом случае ставит и собственный serverTs
meta объект не больше 8 КБ: id — анонимный идентификатор установки, ms — длительность попытки, n — сколько событий свёрнуто (только когда больше одного), harnessCode — вердикт harness таким, каким он его назвал, и t

Каждому понятию соответствует ровно один псевдоним из тех, что принимает коллектор: code, а не type и не kind; message, а не error и не detail; ts, а не timestamp. Анонимный идентификатор живёт в meta.id — отдельного поля для него у API нет, а без него монитор не ответил бы на вопрос «у скольких людей».

Не уходит никогда: промпты, содержание сообщений и чатов, содержимое и пути файлов, API-ключи, идентификатор сессии и вообще всё, что называет пользователя или его машину. Это устройство, а не обещание: в отчёте нет ни одного поля, куда такое можно было бы положить, а единственное свободное — message — проходит через очистку, стирающую ключи вида sk-…, Bearer-токены, заголовки учётных данных, именованные секреты, пути Windows (целиком, вместе с пробелами) и POSIX — они заменяются на <path> — и длинные непрозрачные строки.

Какие сбои считаются аварией

Правило одно: сообщается о том, что сломалось у провайдера, и никогда о том, что произошло у пользователя. Разбор идёт по коду harness, по статусу и по тексту сообщения, и своя ситуация проверяется первой — текст про исчерпанную квоту не становится аварией, даже если в нём встретилось число, похожее на статус.

Считается аварией Не считается
upstream_502, upstream_503, upstream_400, upstream_404 — любой upstream_*, кроме 401, 402, 403 и 429 закончившаяся подписка, заблокированный счёт, исчерпанная квота или кредиты
http_5xx, http_404, http_408 ограничение частоты запросов, включая 429
dns, refused, unreachable, tls неверный, отсутствующий или отозванный ключ
connect_timeout, timeout, reset, server, transport превышенное окно контекста, неизвестная модель, неверный запрос
остановка, нажатая самим пользователем

upstream_400 и upstream_404 сообщаются, а голые 400 и 404 — нет: префикс означает, что шлюз провайдера сам назвал ответ своего источника, то есть сломалось там, а не в запросе, который составили вы.

Успехи никуда не уходят

Коллектор получает только сбои: отчёт об удачном запросе не отправляется никогда. Успехи при этом наблюдаются локально, и нужны они для одного — немедленно отправить ещё сворачивающийся сбой этой модели, как только запрос снова прошёл: отчёт попадает в коллектор в момент восстановления, а не в конце окна сворачивания, и промежуток между двумя метками времени и есть длина аварии. Счётчики, которые успехи двигают, хост-половина по-прежнему отдаёт в errorReport, но страница настроек их не рисует — как и всю остальную сводку сбора.

Следствие стоит назвать прямо: аптайм на мониторе — это отсутствие ошибок в окне, а не отношение двух видов отчётов. Модель, которую никто не спрашивал, выглядит там так же, как модель, которая работает.

Анонимный идентификатор и два файла

Идентификатор — случайный UUID и ничего больше: ни аппаратного идентификатора, ни имени учётной записи, ни пути. Он нужен, чтобы считать людей, а не запросы: тысяча отчётов из одной установки — это один человек, повторяющий попытку, а тысяча отчётов из трёхсот установок — это авария. В отчёте он лежит в meta.id. Сбросить его — удалить <профиль>\.dsh-rustvy\install.json: следующая отправка создаст новый, и установка станет для монитора другой.

Что Где
Анонимный идентификатор <профиль>\.dsh-rustvy\install.json
Неотправленные отчёты <профиль>\.dsh-rustvy\errors-queue.json

Очередь на диске — часть смысла, а не кеш: отчёт, который не удалось доставить, остаётся в файле со своей настоящей меткой времени и уходит, когда сайт отвечает, поэтому авария попадает в монитор с временами, в которые она произошла, а не с моментом восстановления связи. Записывается она не чаще раза в 5 секунд и ещё раз при выгрузке, поэтому перезапуск во время аварии ничего не теряет.

Сворачивание и пределы

Без них установка в циклическом повторе отправила бы коллектору тысячи отчётов об одной и той же аварии:

Что Значение
Окно сворачивания сбоев 60 секунд на пару «модель + вид»: ts остаётся временем первого сбоя, meta.ms берётся от последнего, число событий растёт в meta.n
Отправка очереди раз в 15 секунд, до 10 отчётов за одну отправку — и каждый своим запросом, потому что одного запроса на отчёт требует сам коллектор
Предел в минуту 20 отчётов на установку — прямой ответ на лимит коллектора в 60 отчётов в минуту на IP
Предел на установку 240 отчётов в час; сверх него они отбрасываются, а не копятся
Потолок очереди 500 отчётов; при переполнении отбрасываются самые старые
Повтор после отказа 15 секунд, удваивается до 10 минут
Срок одной отправки 10 секунд
Запись очереди на диск не чаще раза в 5 секунд и ещё раз при выгрузке

Ответы коллектора разбираются по смыслу, а не одним правилом на все 4xx. 200 { ok: true } — отчёт принят. 400 означает, что в отчёте не оказалось ни message, ни code: такой отчёт отбрасывается как непригодный, потому что коллектор, отказавший ему один раз, откажет и снова. 429 и любой 5xx — в том числе 503, когда недоступно хранилище, — оставляют отчёт в очереди и откладывают следующую попытку; заголовок Retry-After в ответе 429 принимается как нижняя граница отсрочки. Сетевая ошибка оставляет отчёт в очереди точно так же — именно поэтому авария доходит до коллектора уже после того, как сайт ответил, и доходит с исходными метками времени.

429 отделён от 400 сознательно: это «занято», а не «запись плохая», и отчёт, отброшенный на чужом лимите, был бы потерянным настоящим сбоем. Лимит коллектора — 60 отчётов в минуту на IP, и наш поток ниже него по построению, а не по договорённости: одинаковые сбои одной модели сворачиваются в один отчёт за 60 секунд, одна отправка уносит не больше 10 отчётов, минутный предел на установку — 20 против их 60, почасовой — 240, а очередь выше 500 отчётов не растёт. То есть не этот плагин приводит кого-то к 429.

Адрес коллектора у всех установок один, поэтому минутный предел защищает ещё и соседей: авария на одной машине не съедает долю в 60 отчётов в минуту, которая у общего NAT одна на всех.

Как выключить

Выключателем «Собирать ошибки rustvy» в Настройки → Rustvy, collectErrors: false в <профиль>\.dsh-rustvy\config.json либо переменной окружения DSH_RUSTVY_NO_ERRORS=1, которая вынуждает молчание независимо от настройки: она для развёртывания, которому нужен смонтированный, но молчащий плагин и которое не хочет править файл профиля. Выключенный сбор не отправляет ничего и ничего нового в очередь не ставит; на сами запросы к провайдеру он не влияет ни в каком состоянии. Форма отчёта, словарь видов и контракт коллектора описаны в docs/error-report.md.

Аптайм моделей

Тот же коллектор, который принимает отчёты об ошибках, отдаёт обратно вид монитора: какие модели отвечают, у скольких людей и как давно они не отвечали. Плагин показывает этот вид прямо в DSH — блоком на странице настроек и значком аптайма в списке моделей, — чтобы выбор модели не требовал открывать браузер и искать там строку, которая и так видна здесь.

Числа в блоке — собственные числа коллектора. Плагин ничего не пересчитывает: оценка, посчитанная локально, разошлась бы со страницей монитора, а два ответа на вопрос «модель жива?» хуже одного.

Процента больше нет

Плагин сообщает только об ошибках: ни удачных запросов, ни объёма трафика у него нет, поэтому у «процента доступности» нет знаменателя в принципе. Число, которое раньше рисовалось на значке, было долей корзин без ошибок, и у него три плохих свойства: оно менялось вместе с окном при одном и том же потоке событий, насыщалось — 47 ошибок в корзине и одна ошибка давали одинаковый вклад, — и не различало широту: сбой у одного человека и авария у провайдера выглядели одинаково.

Хорошо измеряется другое: сколько различных установок сообщили об ошибках, как давно пришла последняя и сколько их было вообще. Поэтому заголовок теперь состояние, а не процент. Единственная доля, которая осталась на экране, — «затронуто N% установок»: различные установки, сообщившие об ошибках в окне, к различным установкам, присылавшим что-либо за 30 дней. Знаменатель у неё настоящий, и означает она охват, а не доступность; аптаймом она не называется нигде.

Откуда читаются

Хост-половина зеркалит ответ коллектора на свой петлевой маршрут GET /dsh-rustvy/uptime.json?range=1h|24h|7d|30d, и панель читает зеркало, а не коллектор напрямую: запрос остаётся в пределах машины, а зеркало держит последний удачный ответ, поэтому недоступный коллектор показывает кешированную картину с явной пометкой, а не пустой блок. Обновляется она каждые 30 секунд, пока страница настроек открыта, — столько же ждёт и своя страница монитора, — и перестаёт спрашивать, как только страница закрыта.

Четыре состояния

Состояние Правило коллектора Слово Цвет
ok в окне ошибок не было Без ошибок #22c55e
single ошибки есть, установка одна Локальный сбой #f59e0b
issues ошибки есть, установок две и больше Сбои #ef4444
down установок три и больше, последняя ошибка не старше пяти минут Лежит #ef4444

Правило и его пороги живут в коллекторе, а не в плагине: плагин берёт готовое status и переводит его в слово и цвет. single янтарный, а issues и down красные намеренно — одна сообщившая установка чаще всего означает её собственную сеть, ключ или регион, и красный цвет на таком вечере был бы криком о чужой беде. Слова и цвета взяты со страницы монитора, чтобы плагин и сайт не рассказывали разное об одной и той же модели; поверхностями при этом остаются обычные токены темы --dsw-alias-*, потому что у страницы монитора одна тёмная тема, а у этой две.

Коллектор старше v2 отвечает словами up | slow | down. Они переводятся как up → ok, slow → issues, down → down, а single от него просто не приходит: пока сервер не обновлён, локальный сбой неотличим от аварии провайдера. Незнакомое слово принимается за ok — модель, о которой в окне ничего не сообщили, это модель, на которую никто не пожаловался, а не пятая категория, которой на проводе не было.

Окна и полосы

Переключатель окна — 1 час, 24 часа, 7 дней, 30 дней, по умолчанию сутки — это состояние самой панели, а не настройка: в config.json он не пишется, и один взгляд на прошлый месяц не становится предпочтением для всех остальных поверхностей. Корзин в ответе столько, сколько прислал коллектор: 60 минутных на час и 48 на каждое окно длиннее, поэтому ширина корзины меняется вместе с окном — минута на часе, полчаса на сутках, три с половиной часа на неделе, пятнадцать на месяце. Полоса несёт подпись своей ширины («1 столбик = 30 мин»), потому что без подписи полоса на неделю читается как полоса на час. Рисуются корзины в масштабе max(1, …counts), то есть одна-единственная ошибка уже видна.

Строка модели

Слово состояния, «ошибок N от M установок», «последняя X назад», полоса корзин с подписью ширины и «затронуто N% установок», когда знаменатель есть; ниже — строка последней ошибки с её временем, кодом и собственным текстом провайдера.

Часть отчётов приходит без пригодного meta.id, и их не на кого посчитать. Тогда «ошибок 3 от 1 установки» молча обещает широту, которой на самом деле не измерили, — а это ровно то число, по которому решают, доверять модели или нет. Поэтому рядом с ним появляется оговорка: «(все анонимные)», когда анонимных столько же, сколько ошибок, и «(2 из 3 анонимных)», когда часть. Когда анонимных нет — а это обычный случай — не пишется ничего.

Ответ коллектора старше v2 не несёт ни installs, ни affectedPct, ни minutesSinceLast. Строка показывает число ошибок без «от M установок», не показывает долю вовсе и берёт «последняя X назад» из lastError; из шапки исчезает член про охват. Панель становится беднее, но не неправильнее: ни «от 0 установок», ни «затронуто 0%» на ней не появляется никогда.

Шапка блока: «моделей N · без ошибок N · локально N · сбои N · лежат N · ошибок за период N · затронуто M из K установок». Процента в шапке нет ни на одном окне, и это не упущение: на 30 днях окно и справочный период совпадают, числитель и знаменатель — одно и то же множество установок, и «затронуто 100% установок» читалось бы как вердикт, означая лишь, что долю измерили саму против себя. Коллектор отвечает на таком окне summary.affectedPct: null, а шапка показывает сами счётчики — «затронуто 47 из 47 установок», — которые остаются честными числами и говорят то же самое без притворной точности. Доля каждой отдельной модели при этом настоящая на любом окне, включая тридцатидневное: её знаменатель — вся база установок, а числитель — установки одной модели, поэтому «затронуто 4,3% установок» в строке модели остаётся осмысленным там, где та же цифра в шапке была бы тавтологией.

Коллектор, который не знает часа

Значки читают окно 1h, а коллектор первой версии его не знает и отвечает на него ошибкой. Плагин при этом обновляется раньше, чем разворачивается коллектор, поэтому такой промежуток — обычное состояние, а не авария: хост-половина отдаёт 502 без единой модели, значки исчезают все — и в списке моделей, и в кнопке выбранной модели, — значки производителя остаются на месте, а панель на сутках продолжает работать как работала. Пустой значок здесь честнее любого числа: окно, которое коллектор не может назвать, это окно, в котором он не назвал ни одной модели. Если переключить панель на час, она скажет об этом словами и назовёт окно — «Коллектор не ответил за период «1h»: …», — потому что «коллектор не ответил» и «коллектор не знает этого окна» требуют разных действий. Транспортная ошибка убирает значки точно так же: в отличие от панели, которая может показать кешированную картину с пометкой о её моменте, значок не умеет сказать «это мы знали некоторое время назад».

Пустой ответ — нормальный

Агрегация коллектора ещё пишется, и он отвечает total: 0, нулём ошибок и пустыми корзинами на всех окнах. Панель рисует это как находку, а не как поломку: «ошибок нет» — ответ, и он показан словами вместо пустых полос. Когда агрегация заработает, те же строки начнут показывать настоящие аварии, и менять для этого страницу не придётся. Это свойство коллектора, а не плагина.

Значки аптайма в списке моделей и в кнопке выбранной модели

У строк, которые коллектор назвал, в списке моделей появляется значок состояния — справа в строке, там же, где harness рисует свою собственную плашку, и рядом с ней, а не поверх неё. Процента на нём нет: ok — тихая зелёная точка без числа, single — янтарная плашка с числом ошибок, issues и down — красная плашка с числом ошибок. Число ошибок и цвет состояния читаются быстрее, чем 97,9%, которые ничего не значили.

Строка, которую коллектор не назвал, не получает ничего — и именно поэтому у ok точка, а не пустое место: «про эту модель ничего не известно» и «у неё всё хорошо» обязаны выглядеть по-разному. Подсказка несёт остальное — «3 ошибки от 2 установок за последний час · последняя 4 мин назад», с той же оговоркой про анонимные отчёты, — а для ok говорит «ошибок не было за последний час».

Тот же значок рисуется и в кнопке выбранной модели — между названием модели и индикатором рассуждений, если сборка его рисует, а если не рисует, сразу за названием. Кнопка единственное место, где модель меняется под значком, поэтому правило одно: при смене выбранной модели значок либо переписывается, либо исчезает — состояние прошлой модели в кнопке не остаётся. Состояния, цвета и окно у него ровно те же, что и у значков в списке, а выключатель «Иконка в кнопке выбранной модели» убирает с кнопки и значок производителя, и значок аптайма: это слово пользователя о том, писать ли в кнопку вообще.

Значок описывает последний час и только его: окно панели на него не влияет, иначе подсказка врала бы о числе, которое показывает. Рисует значки тот же проход, что и значки провайдера, поэтому общий выключатель значков убирает и их, а выключатель «Показывать аптайм моделей» — только их.

Это правило отрисовки, а не новое значение состояния: значок и панель рисуют одно и то же status коллектора, просто значок говорит цветом и числом, а панель — словом, числом и полосой.

Как выключить

Выключателем «Показывать аптайм моделей» в Настройки → Rustvy (по умолчанию включён), uptimeView: false в <профиль>\.dsh-rustvy\config.json либо переменной окружения DSH_RUSTVY_NO_UPTIME=1, которая прячет панель независимо от настройки. Выключенный вид не читает ничего: ни панели, ни значков аптайма в списке моделей нет, и запрос к зеркалу не уходит вовсе. Форма ответа коллектора, его поля и контракт зеркала описаны в docs/uptime-view.md.

Маршруты

Одна регистрация префикса на собственном веб-сервере harness. Маршруты плагинов не видны фильтру запросов хоста, поэтому каждый из них проверяет собеседника сам: только локальный адрес, только локальный Host, только same-origin Sec-Fetch-Site и только совпадающий Origin.

Маршрут Метод Ответ
/dsh-rustvy/catalog.json GET зеркальный каталог плюс настройки, с которыми браузерная половина рисует
/dsh-rustvy/logo/<имя>.svg GET, HEAD один кешированный логотип, при первом запросе забираемый с источника; 404, когда такого значка у источника нет, 400 для любого имени, не совпадающего с [A-Za-z0-9._-]+.svg
/dsh-rustvy/models.json GET сгенерированный список моделей и его расхождение с установленным; ?cached=1 отдаёт последний вычисленный план вместо повторного чтения эндпоинта
/dsh-rustvy/models.yaml GET, HEAD тот же список в виде готовой к вставке строки cordis.patch.yml; ?cached=1 как выше
/dsh-rustvy/state GET состояние зеркала: версия, число моделей, кешированные логотипы, маршрут, прокси (учётные данные маскируются), все пути к файлам — включая install, errorsQueue и uptime, — последняя ошибка, последнее вычисленное расхождение, сводка кольца ключей, блок routeBootstrap и блок errorReport. В routeBootstrap два шага: provider — исход создания маршрута при монтировании (state из created|exists|failed|skipped, число моделей или null, если их никто не считал, source списка, keySet и причина отказа устойчивым кодом) — и defaultModel, который null целиком, если шаг не выполнялся. keySet пересчитывается на каждый запрос, а не замораживается на монтировании, поэтому ключ, добавленный кольцом, виден без перезапуска. В errorReport: включён ли сбор, адрес коллектора, анонимный идентификатор, счётчики отправленного, очереди и отброшенного, последний ответ, последняя ошибка отправки и разбивка сбоев по видам. Собственных запросов не делает; сводка кольца синхронная и без чтения учётных данных, поэтому масок в ней нет — только счётчики и маршруты. Блок errorReport страница настроек не рисует вовсе — он для тех, кто читает маршрут руками; routeBootstrap она рисует условной строкой. У хост-половины старше сбора ошибок нет errorReport и двух новых путей, у хост-половины старше создания маршрута нет routeBootstrap — ключ отсутствует целиком, и страница рисует в этом случае ничего
/dsh-rustvy/refresh POST перечитать каталог, заново прогреть все названные им логотипы и сообщить — либо, когда режим записи позволяет, применить — список моделей
/dsh-rustvy/apply-models POST записать сгенерированный список в профиль маршрута через ctx.configEditor
/dsh-rustvy/revert-models POST вернуть сохранённый профиль маршрута
/dsh-rustvy/config POST записать настройки; сети не касается
/dsh-rustvy/errors GET, POST петлевая диагностика сбора ошибок, которой в интерфейсе больше нет: ни кнопки, ни строки состояния на странице настроек не ведут сюда. GET: сводка сбора — тот же блок errorReport, что и в /state. POST { action: 'test' } ставит в очередь одно синтетическое событие и немедленно его отправляет, { action: 'flush' } отправляет то, что накопилось; оба отвечают свежей сводкой. Отказ — HTTP 200 с { ok: false, error }, как у выключенной возможности на /apply-models. Маршрут остаётся рабочим: им проверяют доставку руками, когда смотреть на экран не хочется
/dsh-rustvy/uptime.json GET зеркало вида монитора: ?range=1h|24h|7d|30d (по умолчанию 24h) и ответ коллектора как он есть — summary, installs, models с их status, errors, installs, anonymous, firstAt, lastAt, minutesSinceLast, affectedPct, buckets, events и lastError, byCode, generatedAt, storage и monitorUrl; устаревшие поля uptime и summary.slow приходят от старого коллектора и плагином не показываются. Когда коллектор недоступен, отдаёт последний удачный ответ с stale: true, fetchedAt и error; когда кеша нет вовсе — 502 с { ok: false, error }. При выключенной настройке — 200 с { ok: false, error }, как у всякой выключенной возможности
/dsh-rustvy/keys GET, POST GET: кольцо целиком — ключи с маской и отпечатком, ключ по умолчанию, скрытые группы провайдеров, отсутствующие и отставшие маршруты, счётчики подмены, последний отказ, пути к обоим файлам, доступность credentials и configEditor. POST: одно действие над кольцом (add, set, rename, default, remove, repair, test); успешный ответ — свежий GET плюс поле результата, поэтому один POST перерисовывает всё. Отказ — 409 с { ok: false, error }, неизвестное действие — 400. Значение ключа принимается только в теле запроса
/dsh-rustvy/session-key GET, POST GET с ?sessionId=: каким ключом этот чат отправляет запросы (keyId), есть ли у него собственный выбор (picked), и целиком тот же ответ, что у GET /keys; без идентификатора — 400. POST { sessionId, keyId } записывает выбор и отвечает тем же payload; keyId: null возвращает чат к ключу по умолчанию

Без перезапусков

Хост-половина перезагружает себя сама, тем же способом, что и dsh-proxy-gui: строка загрузчика называет этот пакет, чья точка входа импортирует реализацию через запрос со сбрасывающим кеш параметром ?rev=, а опрос каталога lib раз в секунду останавливает предыдущую редакцию и запускает следующую, как только изменившийся отпечаток повторится. Поэтому lib/host.js, lib/models.js, lib/keys.js, lib/errors.js, lib/uptime.js и lib/net.js перезагружаются на лету, а перезапуск нужен только при правке самой точки входа. Браузерная половина приходит в открытую страницу через собственную живую композицию плагинов harness; обновление страницы тоже работает.

Название, описание и значок в меню плагинов читаются из пакета на композиции, поэтому изменение locale/*.json или assets/icon.svg требует перезапуска.

Требования и деградация

Проверено на dsh 0.2.1-rc.2. Пакет не объявляет ни engines.dsh, ни peer-зависимостей, потому что не импортирует ни одного модуля @deepseek-ai/* — у него ноль рантайм-зависимостей, а непроверенное требование к версии хоста дало бы в маркете лишь ложные предупреждения о несовместимости.

Каждая возможность деградирует самостоятельно:

Чего нет Что происходит
configEditor расхождение по-прежнему вычисляется и показывается; кнопки записи и отката объясняют, что хост не отдаёт редактор
маршрут dsh-llm-pi-ai значки работают; половина со списком сообщает, что маршрут не найден
источник кеш на диске отдаёт каждый значок и каждую модель, которые в нём есть
эндпоинт при живом каталоге состав определяет остров, и страница об этом говорит
credentials значение ключа некуда записать: кольцо остаётся с одним основным ключом, добавление недоступно, и раздел «API-ключи» называет причину вместо того, чтобы показать форму, которая откажет
configEditor — для кольца значение существующего ключа заменить можно, маршрут для нового добавить нельзя: форма добавления выключена, и раздел объясняет почему
маршрут ключа, потерянный после ручной правки cordis.patch.yml или отката списка моделей ключ остаётся в кольце, его маршрут числится в routes.missing, запросы этого чата идут маршрутом профиля с видимой причиной, а кнопка «Починить маршруты» ({ action: 'repair' }) записывает маршруты кольца заново
коллектор аптайма отчёты остаются очередью на диске и уходят, когда сайт отвечает, поэтому авария попадает в монитор с настоящими временами; на запросы к провайдеру это не влияет никак, а неотправленное и последнюю ошибку отправки показывают петлевые маршруты /dsh-rustvy/state и /dsh-rustvy/errors — на странице настроек этого нет
хост-половина старше сбора ошибок /state приходит без блока errorReport и без двух новых путей: страница настроек рисует выключатель одинаково в обоих случаях,