Перейти к основному содержимому
K

dsh-rustvy

koliy82/dsh-rustvy

Vendor marks in the model picker plus a model list kept in step with the provider: mirrors the public catalog and logos to loopback, and regenerates the pi-ai route's models from that route's own endpoint, showing the drift before anything is written.

Установка

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

README

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

Rustvy (dsh-rustvy)

Русский | English

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

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

Что он делает

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

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

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

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

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

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

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

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

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

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

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

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

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

Установка

dsh plugin --profile web add dsh-rustvy

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

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

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

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

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

pwsh -File .\install.ps1

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

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

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

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

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

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

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

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

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

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

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

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

Настройки

Настройки → Rustvy

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

Значки:

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

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

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

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

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

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

Ключи:

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

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

Сбор ошибок:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ПолеОткуда берётся
idидентификатор отдаваемой модели — то, что нужно назвать в запросе
nameотображаемое имя из острова либо идентификатор, если имени нет
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 — нет: префикс означает, что шлюз провайдера сам назвал ответ своего источника, то есть сломалось там, а не в запросе, который составили вы.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Окна и полосы

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Маршруты

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

МаршрутМетодОтвет
/dsh-rustvy/catalog.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, — последняя ошибка, последнее вычисленное расхождение, сводка кольца ключей и блок errorReport: включён ли сбор, адрес коллектора, анонимный идентификатор, счётчики отправленного, очереди и отброшенного, последний ответ, последняя ошибка отправки и разбивка сбоев по видам. Собственных запросов не делает; сводка кольца синхронная и без чтения учётных данных, поэтому масок в ней нет — только счётчики и маршруты. Блок errorReport страница настроек не рисует вовсе — он для тех, кто читает маршрут руками; у хост-половины старше сбора ошибок его и двух новых путей нет
/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 возвращает чат к ключу по умолчанию

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

Хост-половина перезагружает себя сама, тем же способом, что и 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значки исчезают все — и в списке моделей, и в кнопке выбранной модели, — значки провайдера остаются, а панель на сутках работает как прежде и называет окно, которое не смогла прочитать, если переключить её на час. Это состояние между обновлением плагина и развёртыванием коллектора, и пустой значок в нём честнее числа
коллектор старше v2, без полей широтыпанель переводит старые слова состояний, показывает число ошибок без «от M установок», не показывает долю вовсе и берёт давность из lastError; из шапки исчезает член про охват. Ни «от 0 установок», ни «затронуто 0%» на ней не появляется

Структура

ФайлРоль
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, а в выключенном виде панель объясняет себя словами и не делает ни одного запроса к зеркалу.

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

Лицензия

MIT — см. LICENSE.

Похожие плагины