DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录
← Plugins
R

dsh-rustvy

Rustvy

Rustvy for DeepSeek Harness:模型选择器中的供应商标识、与供应商实际提供的服务保持同步的模型列表、延长的流式空闲超时以避免长时间推理模型被适配器的监视程序中断、在测定 DPI 过滤器阻止模型流量时用于传输的 ClientHello 分片机制

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add github:koliy82/dsh-rustvy#0778d46f75bdaf9c031ee9303f18deca5a62bb46
README兼容性版本

说明

Rustvy for DeepSeek Harness:模型选择器中的供应商标识、与供应商实际提供的服务保持同步的模型列表、延长的流式空闲超时以避免长时间推理模型被适配器的监视程序中断、在测定 DPI 过滤器阻止模型流量时用于传输模型流量的 ClientHello 分片机制、仅作用于 rustvy 流量且无需重启即可切换的可选 HTTP(S)/SOCKS5 代理,以及一个 API 密钥环,使每个聊天都能使用各自的密钥发送请求——主机端将目录和徽标镜像到回环地址,从路由自身的端点重新生成 pi-ai 路由的模型,并在写入任何内容之前显示差异;同时为每个密钥克隆一次该路由,因此密钥值会保留在 harness 凭据存储中,永远不会出现在插件文件里。

兼容性与来源证明

Rustvy 以 dsh-rustvy 发布,当前版本为 1.6.0。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
web
发布来源
github
Registry 更新时间
2026/11/4

版本

1.6.0stable
2026/11/4
1.5.0stable
2026/11/4
1.3.1stable
2026/11/1
查看其余 6 个版本收起版本
1.3.0stable
2026/10/31
1.2.3stable
2026/10/29
1.2.2stable
2026/10/26
1.2.1stable
2026/10/25
1.2.0stable
2026/10/24
1.0.0stable
2026/10/19

相关插件

正在加载相关插件…

最新版
1.6.0
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
未提供
文件数
未提供
Surface
web
许可证
MIT
发布源
github
GitHub
★ 1
周下载
0
最近提交
2026/11/4
查看源码 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题

相关插件

继续浏览 models-usage 分类下经过校验的插件。

Experimental Cot Translation Bundle@deepseek-ai/dsh-experimental-cot-translation-bundle扩展推理的实验性机器翻译Whale Widgetdsh-whale-widgetDSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义Our Free Model@ebony-vinyl/dsh-our-free-modelOur Free Model — 在 dsh 里装上这个插件即可,无需登录、注册或填 API Key,就能使用包括 DeepSeek V4.1 Flash、Kimi K3 在内的前沿模型,桌面端另有 EAC 协付渠道;2.0 起接入 LobsterAI、Qoder、TRAE、Cline、Loomy、Raccoon、MiniMax、ZCode、Gemini 十个账号渠道(原有匿名免费通道保留)(登录、账号池、积分领取、本地 OpenAI 网关与局域网转发、数据看板与运行日志),六页毛玻璃界面统一呈现。Usage@linxin666/dsh-usage用于 dsh Web GUI 的使用统计插件:检测各提供商的余额和编程套餐配额,并实时记录令牌使用情况;侧边栏 Settings 行上方显示紧凑的价格概览卡片

README

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

Rustvy (dsh-rustvy)

Русский | English

Значки производителей в списке моделей 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» и в самом низу «Зеркало каталога» с его адресами и файлами. Аптайм первый, потому что это единственный блок, отвечающий на вопрос, с которым страницу открывают, — жива ли модель, которую сейчас выберут; зеркало последнее, рядом с предупреждением о неудачном чтении каталога, которого только оно и касается.

В блоке «Зеркало каталога», рядом с другими сетевыми фактами, живёт прокси плагина: переключатель семейства «Тип прокси» с двумя положениями — HTTP(S) и SOCKS, — поле адреса, выключатель, «Записать» и «Проверить прокси». Схемы в адресе нет: поле принимает логин:пароль@хост:порт, а семейство выбирается только переключателем, поэтому строка и переключатель не могут разойтись. HTTP(S) означает «прокси, говорящий по HTTP», и в этом выпуске соединение с ним всегда открытое: автоматическое определение шифрования не реализовано. Клиент уже готов напечатать фразу о транспорте, как только серверная половина начнёт её присылать, — до тех пор она молчит, а не ставит прочерк. SOCKS означает SOCKS5, а SOCKS4 отклоняется громко и текстом, указывающим на переключатель. Семейство не выводится из адреса: наличие пароля не говорит ничего ни о шифровании соединения с прокси — HTTP-прокси с паролем обычный случай, — ни о версии рукопожатия SOCKS, где сервер SOCKS5 без аутентификации всё равно отвергнет приветствие SOCKS4. Смена положения перестраивает транспорт даже при том же хост:порт, поэтому переключатель пишет сразу, а не по «Записать». Строка состояния над ними говорит одно из четырёх: установленный прокси показывается замаскированным адресом, выключенный — словом «выключен», включённый, но не установившийся, — собственными словами хоста в предупреждающем цвете, а у хоста, который поле не публикует, стоит прочерк. Само поле — единственное место на странице, где виден адрес целиком, вместе с паролем: оно обязано показывать ровно то, что записано, даже если строку не разобрать, потому что поле, тихо поправившее значение, разошлось бы с файлом незаметно.

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

Значки:

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

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

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

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

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

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

Ключи:

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

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

Сбор ошибок:

ПараметрПо умолчаниюСмысл
Отправлять успешные запросывклзнаменатель процента аптайма. Успехи агрегируются в окно (по умолчанию 1 минута) и уходят одним отчётом, а не по одному на каждый запрос: агрегация убирает 21 % запросов против отправки по одному, а при прежнем окне в 15 минут убирала 86 % — окно стало сравнимо с промежутком между успехами, и задержка процента была ценой этой экономии. Пустое окно не отправляется вовсе, поэтому простаивающая машина не платит ничего — это ноль байт, а не округление. Выключение останавливает отправку, но не учёт: счётчик продолжает закрывать свёрнутые отказы и отвечать на вопрос, жив ли наблюдатель
Собирать ошибки rustvyвклобщий выключатель сбора: одна строка на инфраструктурный сбой провайдера уходит коллектору аптайма. Выключенный не отправляет ничего, и накопленная очередь остаётся на диске

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

Под выключателем успехов — одно короткое пояснение о цене, потому что пользователь спросил именно про трафик: сколько стоит один отчёт и сколько выходит за рабочий день. Числа в нём замерены на настоящем документе, а не взяты из оценки, и идут вместе с условиями замера; рядом появляется собственный итог этой установки — сколько окон и байт ушло с начала работы, — когда хост их публикует. Числа, которого состояние не содержит, страница не рисует вовсе: выдуманная оценка трафика на экране хуже отсутствия оценки. Кнопок отправки или повтора в блоке нет и не будет: окно сбрасывается собственным таймером, отдельной конечной точки для досрочного сброса нет, поэтому кнопка «отправить сейчас» могла бы лишь перечитать состояние, которое ничего не перезапускает, — элемент с подписью «повторить», который не повторяет. Отдельной строкой, и только когда счётчик ненулевой, блок сообщает «N окон потеряно» — сколько окон успехов сформировалось на этой установке и не дошло до коллектора; нулевая потеря не рисуется, потому что строка, пустая у большинства установок всю жизнь, учит не читать блок. Что именно уходит, что не уходит никогда и когда сбор срабатывает, описано в разделе «Сбор ошибок и аптайм» ниже и в 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отображаемое имя из острова либо идентификатор, если имени нет
contextWindowcontext_k × 1000
maxTokensmax_output_tokens
inputmodalities, отфильтрованные до text/image
reasoningEffortsreasoning_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_2rustvy-k2rustvy · <название ключа>
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.

Поле запросаЧто несёт
plugindsh-rustvy
versionверсия пакета
providerимя маршрута, которым шёл запрос
modelидентификатор модели
codeвид сбоя из собственного словаря плагина: upstream_502, connect_timeout, dns, …
statusHTTP-статус, и только когда он есть: без соединения поля нет вовсе, а не 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 — нет: префикс означает, что шлюз провайдера сам назвал ответ своего источника, то есть сломалось там, а не в запросе, который составили вы.

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

Коллектор получает не только сбои: успехи тоже уходят, но не по одному, а агрегированными в пятнадцатиминутное окно — один документ на окно на маршрут, со счётчиком по каждой модели. Установка, которая сделала 230 запросов за сутки, отправляет около 32 небольших документов, а не 230; установка, которая не работала, не отправляет ничего вовсе. Замеренный объём — 534 байта на документ при двенадцати моделях и около 20 КиБ в сутки вместе с заголовками HTTP; таблица, сценарии и способ воспроизвести замер лежат в docs/error-report.md.

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

Аптайм на мониторе поэтому настоящее отношение — ok / (ok + fails), а не отсутствие ошибок в окне. Модель, которую никто не спрашивал, больше не выглядит так же, как модель, которая работает: у неё нет знаменателя, и вместо процента рисуется прочерк. Прочерк, ноль и число — три разных утверждения, и смешать любые два из них значит соврать на экране.

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

Идентификатор — случайный 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
Предел на установку1200 отчётов в час, выведенный из минутного (20 × 60); сверх него отправка останавливается до конца часа, а очередь сохраняется и уходит после его начала — отбрасываются только окна успехов, потому что их негде хранить
Потолок очереди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, почасовой — 1200, а очередь выше 500 отчётов не растёт. То есть не этот плагин приводит кого-то к 429.

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

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

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

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

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

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

Процент вернулся — вместе со знаменателем

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

Теперь successes собираются отдельной настройкой, и процент настоящий:

$$\text{аптайм} = \frac{ok}{ok + fails} \times 100$$

где ok — успешные запросы за окно, а fails — сумма счётчика повторов n по всем отказам. Отказ, свёрнутый из трёх попыток в одну запись, даёт fails: 3, а не 1, и процент считается от трёх: свёртывание экономит место в хранилище, а не уменьшает аварию.

Правило нулевой способности. Если ok + fails == 0, процента нет, и поле равно null, а не 100 и не 0. «100%» про окно, в котором не произошло ничего, — утверждение, которое никто не проверял; «0%» про то же окно — клевета. Страница рисует в этом случае прочерк. Три утверждения обязаны различаться на экране, и это закреплено проверкой: число, прочерк и ноль — три разные строки, а не одно значение и два отсутствия. Окно только с успехами честно даёт 100, окно только с отказами честно даёт 0 — и именно этот случай прежняя схема не могла показать вообще.

Процент старого коллектора не рисуется. Коллектор старше v3 тоже присылал поле uptime, но считал его без единого успешного запроса в знаменателе — то есть ровно то число, которое предыдущая версия правила сняла с экрана. Оно не возвращается только потому, что имя поля вернулось: процент разрешает версия контракта, а не сам процент и не соседние поля. Нормализатор плагина рисует uptime только тогда, когда приёмник опубликовал contractVersion числом и не меньше 3, и рисует прочерк в противном случае.

Угадывать версию по наличию fails или ok нельзя, хотя соблазн понятен: эти поля появились в v3. Такой вывод привязывает смысл поля к тому, что оказалось заполнено, а не к тому, что производитель имел в виду; он не отличает v3 от будущего v4, который переопределит uptime ещё раз; и он заставляет каждого потребителя заново выводить факт, который производитель уже знает и может сообщить одним целым числом. Полное обоснование, включая то, чего этот аргумент не утверждает, — в docs/uptime-view.md.

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

Доля «затронуто N% установок» осталась на экране и означает по-прежнему охват, а не доступность: различные установки, сообщившие об ошибках в окне, к различным установкам, присылавшим что-либо за 30 дней. Теперь рядом с ней настоящий процент, и подписи у двух величин разные, потому что одно число про трафик, а другое — про то, насколько широко известны отказы. Аптаймом охват не называется нигде.

Заголовок панели считает сбои, а не отчёты. «Сбоев за период» — это fails, сумма n, то есть настоящие отказы; прежнее «ошибок за период» было числом записей и на живых данных занижало на четверть (21 опубликованная запись против 28 настоящих сбоев). Подписи двух величин различаются там, где видны оба числа: «сбоев» для отказов и «ошибок» для записей, потому что одно слово над двумя величинами — это два утверждения под одним именем. Правило состояния при этом по-прежнему читает записи, и подмена не может ему противоречить: n никогда не бывает нулём и его отсутствие читается как единица, значит записей нет тогда и только тогда, когда нет сбоев.

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

Хост-половина зеркалит ответ коллектора на свой петлевой маршрут 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 он не пишется, и один взгляд на прошлый месяц не становится предпочтением для всех остальных поверхностей. Корзин в ответе столько, сколько прислал коллектор, и ширина корзины обязана быть целым кратным окну сбора успехов: процент невозможен для корзины уже окна, а распределить пятнадцатиминутный успех по минутным корзинам можно, только выдумав распределение — ровно то, от чего предыдущая версия отказалась в полосе истории.

ПериодПрежняя ширинаТеперьКратно окну в 15 минут?
1 час60 × 1 мин4 × 15 мин1 мин не кратно 15, увеличено
24 часа48 × 30 мин48 × 30 мин30 = 2 × 15, без изменений
7 дней48 × 3,5 ч48 × 3,5 ч210 = 14 × 15, без изменений
30 дней48 × 15 ч48 × 15 ч900 = 60 × 15, без изменений

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

Подпись ширины («1 столбик = 15 мин») берётся из самого массива корзин, а не из таблицы по периоду. Это не стилистика: окно сбора настраивается, значит настраиваемое окно означает настраиваемую ширину корзины, а не второе правило о ней. Таблица по периодам была бы вторым таким правилом, и два правила разошлись бы в первый же день, когда окно изменили; рисунок при этом продолжал бы утверждать минутную ширину при пятнадцатиминутных данных. Когда массив не даёт измерить ширину — меньше двух меток времени, — подписи нет вовсе: полоса без подписи честна в своём незнании, а подпись из таблицы сообщила бы разрешение, которого у данных нет. Рисуются корзины в масштабе max(1, …counts), то есть одна-единственная ошибка уже видна.

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

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

Строка ведёт счёт сбоями, а не записями, и причина арифметическая: шапка публикует summary.fails, поэтому строки с числом записей не сложились бы в её итог — заголовок «39» над строками, дающими в сумме 13, и читателю неоткуда узнать, какое из двух чисел неверно. Страница, противоречащая сама себе, — это тот же дефект §5.1, только в двух экземплярах на одном экране. Правило состояния при этом по-прежнему считается по записям: чтение и отображение — разные заботы, и ни порог из §6, ни совместимость из §5.1 не зависят от того, какое число напечатано. Ответ коллектора старше v3 не несёт fails, и тогда строка показывает записи под их собственной подписью — это единственная величина, которую такой ответ содержит.

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

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

Та же ловушка этажом выше, и она стоит отдельного абзаца. Поле installs.anonymous — тоже сумма n, то есть сбои, — но лежит внутри объекта, два других поля которого считают установки. Имя и контейнер согласованы, а семантика нет, поэтому контейнер сам подсказывает неверное чтение: на живом замере это window: 5, reference: 5, anonymous: 4, и фраза «4 из 5 установок анонимные» читалась бы безупречно, сравнивая сбои с установками. Единственный правильный партнёр этого поля — summary.fails, поэтому шапка панели не рисует анонимную долю вовсе, и это закреплено проверкой на отсутствие, а не только комментарием: комментарий рядом с местом рендера — причина, а не механизм.

Ответ коллектора старше 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.

Таймаут простоя стрима

Адаптер @deepseek-ai/dsh-llm-pi-ai вешает watchdog на каждое чтение стрима: если за streamIdleTimeoutMs миллисекунд по стриму не пришло ни одного чанка, соединение рвётся с ошибкой вида pi-ai stream idle timeout after 300000ms. Собственный дефолт адаптера — 300000 мс, пять минут. Модель с длинным reasoning (Claude Opus на high/max) молчит до первого токена дольше, поэтому watchdog обрывает живой запрос на середине. Сам адаптер лежит в бандле харнеса и правке не подлежит — менять нужно конфигурацию маршрута, которую он читает.

Плагин поднимает это значение втрое: с 300000 до 900000 мс, то есть до пятнадцати минут, и этого хватает на первое слово даже очень медленной модели. Значение настраивается в разделе «Таймаут простоя стрима» на странице плагина: пресеты 5 минут (дефолт адаптера), 15 минут (по умолчанию), 30 минут, 1 час, 48 часов и «сбросить», плюс любое целое число миллисекунд от 1 до 2147483647.

Механика, которую стоит понимать правильно:

  • Плагин пишет значение в профили маршрутов в cordis.patch.yml — в rustvy и в клоны кольца ключей (rustvy-k2, rustvy-k3 и далее). Именно их читает адаптер. В собственный config.json плагина значение не пишется: там хранится намерение, а не результат, поэтому строка «сейчас на маршрутах» показывает то, что действительно лежит в профиле, а не то, что плагин собирается применить.
  • Запись происходит только если значение отличается от уже лежащего в профиле. Число, проставленное руками в cordis.patch.yml, переживает каждую загрузку плагина: автоприменение сначала сравнивает, а не перезаписывает при каждом старте.
  • «сбросить» (null) удаляет поле из профилей, и маршруты возвращаются к собственному дефолту адаптера — 300000 мс. Это штатный способ отменить вмешательство плагина.
  • 0 в настройках означает «не трогать маршруты вовсе». Это не значение таймаута, а отсутствие намерения, и до профиля оно не доходит никогда.
  • Ноль записать в профиль нельзя в принципе: схема адаптера отвергает неположительные значения, и маршрут с streamIdleTimeoutMs: 0 перестал бы отвечать вовсе. Поэтому плагин принимает 0 как настройку, но отказывается записать его как значение — и сообщает об этом прямо, а не молча подменяет единицей. Дробные значения отклоняются по той же причине: setTimeout в адаптере их округлил бы, и поле означало бы не то, что ввёл пользователь.

Перезапуск DSH не нужен: значение действует на следующие запросы.

Против блокировки ТСПУ

Раздел «Против блокировки ТСПУ» управляет фрагментацией ClientHello — разбиением первого пакета TLS-рукопожатия на части, чтобы имя хоста (SNI) не читалось целиком в одном пакете. Это уровень транспорта плагина; адаптер модели здесь ни при чём.

Три положения переключателя:

  • выключена — транспорт обычный.
  • auto — начинается обычным транспортом и переключается на фрагментацию только после двух подряд сбоев транспорта. Это положение ничего не стоит, пока не понадобится: дорогие ~1,5 с платятся внутри окна блокировки, а не заранее. Сбоем транспорта считается отказ соединения или таймаут; HTTP-ответ 4xx или 5xx им не считается — транспорт, доставивший 502, работает.
  • always — фрагментировать сразу и всегда.

Два числа рядом: размер фрагмента в байтах (1..64, по умолчанию 16) и пауза между фрагментами в миллисекундах (0..100, по умолчанию 3).

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

Замеренная цена на Windows — около 1,5 с на каждое новое соединение при 16 байт и паузе 3 мс: ClientHello здесь занимает 1587 байт (TLS 1.3 с постквантовым ключевым материалом), то есть 100 фрагментов, а гранулярность setTimeout на Windows составляет около 15,6 мс. Keep-alive означает, что платится это раз на соединение, а не раз на запрос.

Как фрагментация доходит до запросов модели

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

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

Повышение срабатывает только по замеру, поэтому в положении always оно не происходит. Это не оговорка, а намеренное правило: щёлкнутый руками выключатель — не свидетельство того, что обычный транспорт не работает, и ценой реакции на него стали бы ~1,66 с на каждом запросе модели, то есть плата, которую пользователь уже заплатил один раз, взималась бы со всех остальных запросов.

Переключатель «Включить фрагментацию ClientHello и для запросов модели» — теперь принудительное включение, способ не ждать замера. Он по-прежнему выключен по умолчанию и по-прежнему нужен, когда фрагментировать хочется сразу.

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

Фрагментация и прокси сочетаются

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

Замеренная цена: 285 мс на один туннель и 1812 мс на туннель с фрагментацией. Хосты, не входящие в область действия плагина, не затрагиваются вовсе — туннель для них остаётся обычным.

Ограничение, которое нельзя пропустить: сочетается только plaintext-прокси. Прокси с https:// и SOCKS5 по-прежнему идут на собственных агентах undici, и фрагментации внутри них нет. Если у вас прокси https://, не считайте, что сочетание у вас есть, — его нет.

«Диагностика канала»

Кнопка «Диагностика канала» в том же разделе измеряет канал по слоям — DNS, TCP, обычное и фрагментированное TLS-рукопожатие, HTTP-запрос по каждому из них — и выдаёт вердикт. Слои важны тем, что сбой на разных этажах означает разное: «не проверялось» (слой выше уже всё объяснил), «отказ» (пробовали и не вышло) и «нет замера» (слой вообще не сообщён) — три разных состояния, и у них разные способы починки.

Вердикты:

  • ok — канал проходит целиком;
  • dns, tcp — обрыв до рукопожатия;
  • tls — обычное рукопожатие не проходит;
  • tls-plain-blocked — это подпись блокировки ТСПУ: обычное рукопожатие отбрасывают, а фрагментированное проходит. Самый информативный вердикт во время блокировки;
  • frag-unavailable — свидетельство против фрагментации: фрагментированное рукопожатие не прошло. Включать фрагментацию при таком вердикте бессмысленно;
  • http, bad-origin — рукопожатие прошло, но запрос не удался, либо адрес провайдера в настройках неверен и измерять нечего.

И то, что разрывает замкнутый круг «пока заблокировано — не измерить, а когда отпустило — измерять нечего»: при сбое транспорта плагин сам записывает послойный замер в свой каталог состояния. Запись троттлится до одной на 120 секунд и ограничена 40 последними записями. То есть свидетельство из окна блокировки переживает это окно, и его можно показать потом — именно поэтому кнопка полезнее всего во время блокировки, а не после неё.

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

Маршруты

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

МаршрутМетодОтвет
/dsh-rustvy/catalog.jsonGETзеркальный каталог плюс настройки, с которыми браузерная половина рисует
/dsh-rustvy/logo/<имя>.svgGET, HEADодин кешированный логотип, при первом запросе забираемый с источника; 404, когда такого значка у источника нет, 400 для любого имени, не совпадающего с [A-Za-z0-9._-]+.svg
/dsh-rustvy/models.jsonGETсгенерированный список моделей и его расхождение с установленным; ?cached=1 отдаёт последний вычисленный план вместо повторного чтения эндпоинта
/dsh-rustvy/models.yamlGET, HEADтот же список в виде готовой к вставке строки cordis.patch.yml; ?cached=1 как выше
/dsh-rustvy/stateGETсостояние зеркала: версия, число моделей, кешированные логотипы, маршрут, прокси (учётные данные маскируются), все пути к файлам — включая install, errorsQueue и uptime, — последняя ошибка, последнее вычисленное расхождение, сводка кольца ключей, блок routeBootstrap и блок errorReport. В routeBootstrap два шага: provider — исход создания маршрута при монтировании (state из created|exists|failed|skipped, число моделей или null, если их никто не считал, source списка, keySet и причина отказа устойчивым кодом) — и defaultModel, который null целиком, если шаг не выполнялся. keySet пересчитывается на каждый запрос, а не замораживается на монтировании, поэтому ключ, добавленный кольцом, виден без перезапуска. В errorReport: включён ли сбор, адрес коллектора, анонимный идентификатор, счётчики отправленного, очереди и отброшенного, последний ответ, последняя ошибка отправки и разбивка сбоев по видам. Собственных запросов не делает; сводка кольца синхронная и без чтения учётных данных, поэтому масок в ней нет — только счётчики и маршруты. Блок errorReport страница настроек не рисует вовсе — он для тех, кто читает маршрут руками; routeBootstrap она рисует условной строкой. У хост-половины старше сбора ошибок нет errorReport и двух новых путей, у хост-половины старше создания маршрута нет — ключ отсутствует целиком, и страница рисует в этом случае ничего

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

Хост-половина перезагружает себя сама, тем же способом, что и 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 и без двух новых путей: страница настроек рисует выключатель одинаково в обоих случаях, потому что сводку сбора она не рисует вовсе, а пути к файлам показываются только тогда, когда хост их назвал
вид монитора у коллекторазеркало отдаёт последний удачный ответ с stale: true, и панель показывает кешированную картину с пометкой, от какого она момента; без кеша вовсе — 502, и панель называет причину вместо того, чтобы крутиться. Значки аптайма в списке моделей исчезают вместе с данными, а значки провайдера остаются: на запросы к провайдеру это не влияет никак
коллектор старше v2, не знающий окна 1hзначки исчезают все — и в списке моделей, и в кнопке выбранной модели, — значки провайдера остаются, а панель на сутках работает как прежде и называет окно, которое не смогла прочитать, если переключить её на час. Это состояние между обновлением плагина и развёртыванием коллектора, и пустой значок в нём честнее числа

Структура

ФайлРоль
lib/index.jsстабильная точка входа загрузчика; владеет циклом перезагрузки
lib/host.jsмаршруты, зеркало каталога, кеш логотипов, синхронизация моделей, кольцо ключей и подмена маршрута на agent/request, настройки
lib/models.jsчистое отображение: что публикует источник → одна запись профиля маршрута, расхождение двух списков и YAML для ручной правки
lib/keys.jsчистая арифметика кольца: названия, имена учётных данных и маршрутов, документы кольца и выбора чатов, сверка карты маршрутов с кольцом
lib/errors.jsклассификация сбоя на инфраструктурный и собственный, очистка текста от секретов и путей, анонимный идентификатор установки, сворачивающая очередь отчётов и её файл на диске
lib/uptime.jsчистая форма вида монитора: допустимые окна, приведение ответа коллектора к полям, которые рисует панель, обрезка строк и clamping процентов. Не читает сеть и не держит кеш — этим владеет lib/host.js
lib/net.jsодин исходящий GET по маршруту процесса, затем по прокси из окружения
client/client.jsбраузерный набор, написанный вручную: декоратор, страница настроек, переключатель ключа в композере и скрытие групп-клонов в меню моделей
locale/en.json, ru.json, zh.jsonназвание и описание для меню плагинов
assets/icon.svgзначок для меню плагинов
cordis.patch.ymlстрока загрузчика, которую подтягивает список bundles профиля
install.ps1установка в профиль
scripts/prepublish-check.mjsгейт публикации: всё, что может установиться и при этом быть молча неправильным
tests/проверки отображения, эмиттера YAML, кольца ключей, хост- и браузерной половин

lib/models.js, lib/keys.js и lib/uptime.js намеренно чистые — без сети, файловой системы и Cordis, — поэтому отображение можно проверить на реальном ответе источника, арифметику кольца на выдуманном профиле, а форму вида монитора на ответе коллектора, ничего не монтируя. Сети не касается и lib/errors.js: отправитель внедряется извне, именно потому, что рассуждать об очереди, чей транспорт импортирован, невозможно в тот момент, когда лежит сайт, о котором она сообщает. Свои два файла этот модуль читает и пишет сам — идентификатор установки, который можно было бы создать только внутри смонтированного хоста, не на чем было бы строить. lib/host.js владеет всем, у чего есть время жизни, — включая чтение вида монитора и кеш его последнего удачного ответа, — и именно его заменяет перезагрузка.

Тесты

node tests\models-check.mjs              # чистое отображение: живой источник -> записи, расхождение, аудит схемы
node tests\yaml-roundtrip-test.mjs       # эмиттер против двух настоящих YAML-парсеров
node tests\model-sync-host-test.mjs      # хост: подставные webServer и configEditor, настоящий запрос к источнику
node tests\api-keys-host-test.mjs        # кольцо ключей: подставные credentials, configEditor и шина событий, без сети
node tests\error-report-host-test.mjs    # сбор ошибок: классификация, сворачивание и доставка локальному коллектору
node tests\uptime-host-test.mjs          # вид монитора: зеркало, кеш последнего ответа и пометка stale
node tests\model-sync-client-test.mjs    # браузер: мини-React и подставной DOM, фиктивный /state
node scripts\prepublish-check.mjs        # гейт публикации

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

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

Хост-тест прогоняет зарегистрированные маршруты на подставных парах запрос/ответ и подставном configEditor, наполненном маршрутом в том виде, в каком он есть у профиля, и проверяет: план читает эндпоинт; модель, которой зеркало не видело, всё равно получает настоящее отображаемое имя, а не идентификатор; написанный вручную compat переживает перегенерацию и не изобретается для другой модели; /models.yaml выдаёт строку патча, сохраняющую поля подключения и отбрасывающую заменяемый список; запись сохраняет displayName, apiKeyEnv, api и baseURL и заменяет только models; копия ложится на диск; повторное применение ничего не делает и конфигурацию не трогает; /state перестаёт сообщать расхождение, которое запись только что устранила; откат возвращает снятые модели и их compat; нелокальный собеседник получает 403, а неверный метод — 405.

api-keys-host-test.mjs — 84 проверки кольца ключей на той же host-половине, с подставным хранилищем учётных данных, подставным configEditor и подставной шиной событий, без сети вовсе: эндпоинт маршрута смотрит на закрытый порт, поэтому и чтение списка моделей, и проверка ключа детерминированно заканчиваются отказом, а состав списка определяет зеркальный остров. Проверяется подмена: выбранный ключ переименовывает маршрут, а модель, уровень рассуждения и лимит вывода переживают её без изменений; чат без собственного выбора идёт маршрутом профиля; deepseek-account и соседний маршрут с чужим ключом не переименовываются никогда; запрос без сессии остаётся как пришёл; модель, которой клон не отдаёт, оставляет запрос на базовом маршруте и попадает в fallback; чат без выбора следует за ключом по умолчанию, а keyId: null возвращает его обратно. Проверяются отказы: пустой ключ, ключ с пробелом, неизвестное действие (400), удаление основного ключа (409). Проверяется удаление ключа: маршрут клона исчезает, сосед и базовый маршрут выживают, значение стирается из хранилища, выбор чатов с этого ключа сбрасывается, а сам чат продолжает ходить базовым маршрутом. Проверяются записи и счётчики: одно добавление — одна правка конфигурации, замена значения не правит её вовсе, запись списка моделей накрывает клон и сохраняет его собственное имя учётных данных, блок для ручной правки содержит клон, repair возвращает потерянный маршрут, в keys.json нет секрета, выгрузка снимает и маршрут, и слушателя, а с выключенной возможностью не переименовывается ничего.

error-report-host-test.mjs — хост-тест сбора ошибок: классификация сбоев на инфраструктурные и собственные, сворачивание отчётов по окнам и их доставка коллектору, поднятому на локальном адресе, — то есть та же host-половина, на которой работают два предыдущих хост-теста, поэтому маршрут /dsh-rustvy/errors, блок errorReport в /state и очередь на диске проверяются вместе с тем, что именно попадает в отправленный отчёт.

uptime-host-test.mjs — хост-тест вида монитора: зеркало на /dsh-rustvy/uptime.json, три окна, кеш последнего удачного ответа на диске и пометка stale с fetchedAt и причиной, когда коллектор недоступен. Проверяется и то, что зеркало ничего не пересчитывает: ответ коллектора отдаётся как пришёл, а выключенная настройка отвечает { ok: false } и не делает запроса вовсе.

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

Тот же тест рисует и кольцо ключей: раздел «API-ключи» со списком, масками, переименованием по потере фокуса, инлайн-формой «+», проверкой, назначением ключом по умолчанию и удалением в два шага; объяснения на каждый отказ — нет credentials, host-половина устарела, возможность выключена — вместе с возвращением способности, когда причина устранена; переключатель «Ключи на несколько чатов», который читает ответ хоста и пишет своё поле в /config; блок «Маршруты ключей» со счётчиками подмены, числом чатов со своим выбором, путями к обоим файлам, отсутствующим и отставшим маршрутами и кнопкой «Починить маршруты»; предел в 12 ключей; переключатель композера, который не рисуется без sessionId и при одном ключе, читает выбор своего чата, публикует его в /session-key и предлагает добавить ключ; меню выбора моделей и окно /model, которые не получают ни раздела ключей, ни его строк; стоимость обращений к хосту — два взаимодействия подряд стоят одного чтения, открытие переключателя читает всегда, а выбор обходится одним POST без дочитывания; и выгрузка, после которой в меню не остаётся ничего своего. Отдельно проверяется, что значение ключа не попадает ни в один URL и не остаётся на странице после добавления.

Он же рисует блок сбора ошибок: выключатель с его точной подписью — включённый по умолчанию, в том числе когда фиктивный /state поля collectErrors не знает вовсе. Отдельно проверяется отсутствие, и не только сводки: фикстура несёт полный блок errorReport, и при этом на странице нет ни заголовка «Состояние сборщика», ни одной из восьми строк сводки, ни их значений — ни анонимного идентификатора, ни разбивки по видам, ни момента последней отправки, — ни одной из двух кнопок отправки, и в записанных запросах нет ни одного обращения к /dsh-rustvy/errors. Ровно так же проверяется снятый со страницы текст: девятнадцать строк, которые она раньше несла — вступление под заголовком, обещание под выключателем сбора и семь абзацев в конце, — ищутся в дереве и не находятся, а сами возможности при этом на месте. Удаление, которое никто не проверяет, возвращается само. Единственная строка, пережившая снятие текста, проверена положительно: предупреждение «Каталог не получен» рисуется, когда фиктивный /state несёт error, и не рисуется, когда не несёт, — это не проза, а единственное место, где страница сообщает о неудачном чтении каталога. Проверяется и хост-половина старше сбора ошибок: без блока errorReport страница рисует ровно то же, что и с ним, и не бросает исключение.

Он же рисует блок аптайма — на четырёх фикстурах: всезелёный ответ, который коллектор даёт чаще всего; наполненный, несущий все четыре состояния и оба крайних случая (affectedPct: null и окно, где все отчёты анонимные); часовой ответ, из которого значки берут свои числа, — а на любое окно длиннее зеркало в этом режиме отвечает удвоенными счетчиками, поэтому значок, перерисованный не из своего окна, виден сразу; и ответ коллектора старше v2, с тремя старыми состояниями, процентом и без единого поля широты. Проверяются шапка, считающая состояния вместо процента, и строки моделей со словами «Лежит», «Сбои», «Локальный сбой» и «Без ошибок»; окно: переключатель предлагает все четыре окна, по умолчанию стоит 24h, спрашивает зеркало про ?range=1h и не пишет ничего в /config, потому что это состояние панели, а не настройка; ширина корзины подписана — 48 столбиков по 30 минут на сутках и 60 по минуте на часе; «ошибок 5 от 3 установок» и «затронуто 6,4% установок» — числа коллектора, а не пересчёт; доля без знаменателя рисуется как «—», а не как 0%; последняя ошибка трёхчасовой давности читается как «3 ч назад», а не как «только что»; оговорка про анонимные отчёты стоит рядом с числом, которое она ограничивает; кешированный ответ показывает fetchedAt и предупреждение с причиной; недоступный коллектор называет причину и оставляет кнопку «Обновить» вместо вечного ожидания. Старый ответ рисуется беднее, но не неправильнее: старые слова переведены, число ошибок показано без «от M установок», доля не показана вовсе, и ни «от 0 установок», ни «затронуто 0%» на панели не появляется. Пятая фикстура — тридцатидневное окно, на котором доля в шапке теряет смысл: шапка показывает счётчики («затронуто 47 из 47 установок») и не показывает процента, а доля отдельной модели на том же окне остаётся настоящей («затронуто 6,4% установок»), и полоса говорит, что её столбик — пятнадцать часов.

Отдельно проверяется промежуток между обновлением плагина и развёртыванием коллектора: фикстура, в которой range=1h отвечает 502 без единой модели, а сутки работают как прежде. Проверяется то, что иначе осталось бы догадкой, — путь значков не бросает исключение и не зависает на незавершённом чтении: значки исчезают все, включая значок в кнопке выбранной модели, значки предыдущего окна на экране не задерживаются, значки провайдера остаются, панель называет окно, которое не смогла прочитать, а на сутках продолжает рисовать строки. Транспортная ошибка — чтение, которое не ответило вовсе, — проверяется тем же образом: значки уходят, а не застывают, потому что значок, в отличие от панели, не умеет сказать, от какого он момента.

Отдельно проверяются значки аптайма в списке моделей — на часовой фикстуре, по одной модели на каждое состояние: ok — тихая точка без числа, single — янтарная плашка с числом ошибок, issues и down — красные плашки со своими числами, и все четыре цвета — цвета монитора; точка при этом отличается от пустого места, поэтому «коллектор ничего не сказал» и «ошибок не было» выглядят по-разному; подсказка называет окно («за последний час»), число ошибок, число установок и давность; значок стоит в строке до её собственного хвостового элемента и не теряет значок провайдера; второй проход не рисует второй значок; строка, на которой уже есть плашка harness, сохраняет обе — наша оказывается соседом, а не вложенным элементом; переключение панели на 30 дней не перерисовывает значки, у которых в подсказке час; выключенный вид убирает значки и оставляет значки провайдера, а выгрузка забирает их с собой. Отдельно проверяется значок в кнопке выбранной модели: он несёт состояние выбранной модели и её цвет, стоит между названием и индикатором рассуждений, а на кнопке без индикатора — сразу за названием; собственная обвязка кнопки (индикатор, «Default», шеврон, кнопка остановки) и значок провайдера остаются нетронутыми; при смене выбранной модели значок переписывается — точка тихой модели становится красной плашкой сломанной, — а на модели, которую коллектор не назвал, исчезает, и состояние прошлой модели в кнопке не задерживается; выключенная «Иконка в кнопке выбранной модели» убирает с кнопки и значок аптайма, оставляя значки в списке моделей. Выключатель аптайма проверяется так же, как остальные: постит своё поле в /config, а в выключенном виде панель объясняет себя словами и не делает ни одного запроса к зеркалу.

Он же рисует строку загрузки маршрута — по всем клеткам матрицы «состояние × задан ли ключ», включая те, где правильный ответ является молчанием. Созданный маршрут показан с числом моделей; отсутствующий ключ source переживается без догадки; переключённая модель по умолчанию называет все три пришедшие строки; keySet: false утверждает и направляет, true про ключ не говорит ничего, а null только направляет — и отрисованный текст в последнем случае не содержит «не задан», иначе две фразы слились бы при первой же переформулировке и вернули бы «ключ ещё не задан» на машину, которая работает. Маршрут, который уже был, не рисует ничего, кроме строки про ключ, когда ключа нет, и эта клетка проверена с двух сторон: на молчание при заданном ключе и на отсутствие слова «создан» там, где плагин маршрут не создавал. Несозданный маршрут называет причину словами хоста, действием рядом с ней идёт перезагрузка окна, а не кнопка обновления каталога — при мёртвом источнике зеркало собирается с него же, — и про ключ в этом состоянии строка не говорит вовсе: харнесс разрешал бы apiKeyEnv для маршрута, которого нет. Незнакомый код причины и незнакомое состояние печатаются как пришли, а не заменяются догадкой. Кнопки внутри строки нет ни в одной клетке, и это утверждается, а не наблюдается. Отдельно проверена свежесть keySet: добавление ключа кольцом заставляет страницу перечитать /state, а выбор ключа по умолчанию — нет, потому что наличие ключа он не меняет. Утверждение сделано про запрос, а не про текст: набор перерисовывает эффекты на каждом проходе, поэтому текстовая проверка прошла бы и без правки и ничего бы не доказала. Отсутствие routeBootstrap целиком — не крайний случай, а популяция: так отвечает любая установка с сегодняшней половиной хоста, и она не рисует ничего и не бросает.

Он же рисует процент аптайма — и проверяет, что три его исхода различимы на экране: замеренное число, прочерк и настоящий ноль дают три разные строки, а не одно значение и два отсутствия. Отдельно закреплено, что процент старого коллектора не рисуется: строку разрешает знаменатель, а не имя поля, поэтому uptime без fails рядом остаётся за бортом — иначе снятое когда-то выдуманное число вернулось бы на экран под подписью настоящего. Прочерк не объясняется причиной: выключенные успехи этой установки не превращаются в объяснение нулевого знаменателя у коллектора. Часовой период проверяется на 4 корзины и подпись «1 столбик = 15 мин», причём подпись измеряется по массиву — это то утверждение, которое было верным при прошлой ширине и покраснеет в момент, когда половины встретятся, а не на интеграции. Заголовок панели проверяется на «сбоев за период», а не на прежнее число записей, и на отсутствие обоих чисел под одной подписью.

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

Половина, рисующая значки, автоматическим набором в этом репозитории не покрыта — и это стоит знать заранее. Она работает с DOM уже открытой страницы, поэтому проверяется глазами: какие строки получают значок и какой именно, что строки уровней усилия, заголовки производителей и поле поиска остаются пустыми, что вместо неполученного логотипа появляется плашка с монограммой, что добавленная позже строка тоже получает значок, что повторный проход ничего не удваивает и что выгрузка убирает добавленное. model-sync-client-test.mjs покрывает страницу настроек и переключатель ключа в композере, но не рисование значков; скрытие групп-клонов в меню — та же работа с DOM уже открытой страницы, и набор проверяет там только то, что меню не получает ни раздела ключей, ни его строк.

Лицензия

MIT — см. LICENSE.

routeBootstrap
/dsh-rustvy/refreshPOSTперечитать каталог, заново прогреть все названные им логотипы и сообщить — либо, когда режим записи позволяет, применить — список моделей
/dsh-rustvy/apply-modelsPOSTзаписать сгенерированный список в профиль маршрута через ctx.configEditor
/dsh-rustvy/revert-modelsPOSTвернуть сохранённый профиль маршрута
/dsh-rustvy/configPOSTзаписать настройки; сети не касается
/dsh-rustvy/errorsGET, POSTпетлевая диагностика сбора ошибок, которой в интерфейсе больше нет: ни кнопки, ни строки состояния на странице настроек не ведут сюда. GET: сводка сбора — тот же блок errorReport, что и в /state. POST { action: 'test' } ставит в очередь одно синтетическое событие и немедленно его отправляет, { action: 'flush' } отправляет то, что накопилось; оба отвечают свежей сводкой. Отказ — HTTP 200 с { ok: false, error }, как у выключенной возможности на /apply-models. Маршрут остаётся рабочим: им проверяют доставку руками, когда смотреть на экран не хочется
/dsh-rustvy/uptime.jsonGETзеркало вида монитора: ?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/keysGET, POSTGET: кольцо целиком — ключи с маской и отпечатком, ключ по умолчанию, скрытые группы провайдеров, отсутствующие и отставшие маршруты, счётчики подмены, последний отказ, пути к обоим файлам, доступность credentials и configEditor. POST: одно действие над кольцом (add, set, rename, default, remove, repair, test); успешный ответ — свежий GET плюс поле результата, поэтому один POST перерисовывает всё. Отказ — 409 с { ok: false, error }, неизвестное действие — 400. Значение ключа принимается только в теле запроса
/dsh-rustvy/session-keyGET, POSTGET с ?sessionId=: каким ключом этот чат отправляет запросы (keyId), есть ли у него собственный выбор (picked), и целиком тот же ответ, что у GET /keys; без идентификатора — 400. POST { sessionId, keyId } записывает выбор и отвечает тем же payload; keyId: null возвращает чат к ключу по умолчанию
коллектор старше v2, без полей широтыпанель переводит старые слова состояний, показывает число ошибок без «от M установок», не показывает долю вовсе и берёт давность из lastError; из шапки исчезает член про охват. Ни «от 0 установок», ни «затронуто 0%» на ней не появляется