> **Регистрация на Rustvy по реферальной ссылке автора:** > Скидка 10% и бонусные токены при регистрации по этой ссылке. > Подробные условия: # Rustvy (`dsh-rustvy`) [Русский](README.md) | English [![npm](https://img.shields.io/npm/v/dsh-rustvy)](https://www.npmjs.com/package/dsh-rustvy) [![downloads](https://img.shields.io/npm/dm/dsh-rustvy)](https://www.npmjs.com/package/dsh-rustvy) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-blueviolet)](https://github.com/topics/dsh-plugin) Vendor marks in the DeepSeek Harness model picker, a model list that stays as current as the provider's own, and several provider keys in one profile — each chat sending its requests with the one its user picked. One plugin, one mirror, no restarts. ## What it does It has three jobs. The first two share one mirror of the provider's public catalog; the third needs no mirror at all, only the profile's route. **Marks.** One monochrome vendor mark before every model row in the picker, drawn the way the provider's own dashboard draws it: a CSS mask filled by `currentColor`, so a single file per vendor serves both palettes and every row state — hover, selected, disabled — with no colour variants. | Surface | What appears | |---|---| | Composer model menu | one 16 px monochrome mark before every model row | | Composer model button | the selected model's mark before its name (14 px) | | `/model` command popup | the same mark on every row | | A skin that redraws the picker (`dsh-claude-style`) | the same mark, and the skin's own lockup is hidden in that row so two never meet | **The model list.** A profile reaches a third-party provider through a hand-declared `@deepseek-ai/dsh-llm-pi-ai` route, and that route's `models` list decides what the GUI may select — the harness offers a model only when the route serves it. A hand-written list goes stale the day the provider ships a model: the new one is invisible, and a retired one stays selectable and fails at request time. This plugin reads what the route's own endpoint serves, turns it into that adapter's exact entry shape, shows the difference against what is installed, and writes it back through `ctx.configEditor` when asked. **Keys.** Several provider keys in one profile, and a key of its own for any one chat — chosen in the composer's tool row, right after the access-mode dropdown. A key is added inside the plugin, with a **«+»** button: no `.env`, no `cordis.patch.yml` and no trip to the Models page, and the choice survives a restart. The built-in DeepSeek account is untouched: the substitution only ever applies to this plugin's own routes. | Surface | What appears | |---|---| | **Settings → Rustvy** | an «API-ключи» section: the key list, a **«+»** button, and per key — replace the value, rename, test, make default, delete | | The composer, right after the access-mode dropdown | a compact switch for this chat's key, with a menu and a **«+ Добавить ключ»** row | There is no key section in the model menu or in the `/model` popup: the choice lives in one place. What the model menu gets instead is the removal of the provider groups that turn out to be this ring's route clones — otherwise the same model list would be repeated once per key. ## Why you would want it Because the two failure modes of a hand-maintained provider route are both silent. A model the provider added does not appear, and nothing tells you why — there is no error, just an absence. A model the provider retired still appears, and selecting it produces a transport failure that reads like a network problem rather than like a stale line in a YAML file. Both are fixed by reading the provider instead of transcribing it. The list this plugin writes carries the real context window, output cap, input modalities and reasoning levels, so a model that gained image input or a new thinking level is described correctly the same day, rather than inheriting a fallback somebody guessed at. And because the marks come from the same mirror, a brand-new model arrives with its vendor's own mark already cached — the mirror re-reads the catalog the moment the endpoint names a model it has never seen, which is exactly when a release lands. The third job answers a different kind of quiet. A provider key is its own limit and its own billing, and a profile is one, so "a key per chat" without the plugin means either a second profile or a second row in YAML that somebody has to maintain by hand. A hand-maintained clone of a route goes stale on exactly the same schedule as a hand-written model list — the day of a release. The ring writes its clones itself, in the same pass that writes the model list, so a key's route cannot lag behind the profile's route and a chat sending with another key sees the same choice of models as its neighbour. Nothing here is specific to one provider. The origin, the route and the profile row are all settings; `https://rustvy.xyz` and `rustvy` are defaults. ## Install ```sh dsh plugin --profile web add dsh-rustvy ``` Or straight from GitHub, if you want the latest revision rather than the published version: ```sh dsh plugin --profile web add github:koliy82/dsh-rustvy ``` A bare `koliy82/dsh-rustvy` without a scheme is not accepted: the install-spec parser knows a registry name, a git spec, a tarball and an absolute local path, and a git spec needs either a scheme or the `github:` shorthand. Neither route asks for build approval, because the package has no install script and no dependencies — there is nothing for pnpm to build. From a checkout of this repository: ```powershell pwsh -File .\install.ps1 ``` `install.ps1` copies the package into `\node_modules\dsh-rustvy` as a real directory and appends the name to `dsh.profile.bundles` in the profile manifest. A running harness picks the row up from the manifest change. Then restart the harness once, or refresh the page: the marks and the settings page arrive live, and the loader row is read at composition. ### The route the model list is written into The marks work with nothing else installed. The model-list half needs a `@deepseek-ai/dsh-llm-pi-ai` route to write to — add this to the profile's `cordis.patch.yml`, or edit it in **Settings → Plugins → Plugin configuration**: ```yaml - 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 ``` The `models` list only has to parse: this plugin replaces it. Every other field is carried through untouched by a regeneration, because a wire-compatibility switch or a credential reference is knowledge about your deployment that no catalog can supply. `apiKeyEnv` names where the route's key comes from, resolved per request through the harness credential seam — the inherited process environment, then `$DSH_HOME/.credentials.yaml`, then a `.env` in the invocation directory or in `$DSH_HOME`. The key never enters a configuration file. This plugin's own listing read attaches a key only when the process environment already carries one under that name; the endpoint it was written against answers without one. To make a model the default: ```yaml - id: agent-default-model name: "@deepseek-ai/dsh-agent-default-model" config: provider: rustvy model: claude-opus-5-5 reasoningEffort: high ``` ### A clean profile: the route is created for you Everything above assumes a profile that already has the route. A clean DSH has none, and this plugin used to do nothing and say nothing in that case: the model list kept the built-in DeepSeek, whose key nobody has, so nothing worked at all. Now the host half checks at mount whether the `llm-pi-ai` row has a `rustvy` provider, and creates one when it does not — the address, the name, `apiKeyEnv`, the API shape, and a model list read from the provider. An existing route is never overwritten, in any field: `displayName`, `apiKeyEnv`, `baseURL` and `models` may have been corrected by hand, and that is knowledge about your deployment which no catalog holds. **DeepSeek is never touched.** It lives in other rows of the composition (`llm-deepseek`, `llm-deepseek-account`), and the only write goes into the `llm-pi-ai` row, and inside it only into the `rustvy` key, preserving any other provider of that same row. That is pinned by a test comparing the `llm-deepseek` row byte for byte before and after, not by an argument that the write should not reach it: the user's requirement "do not touch deepseek at all" is absolute, and now that two rows are written instead of one it matters more, not less. **No key is asked for at install time.** That is deliberate: creating the route must not demand anything, and a route without a key is exactly the state after which the model list appears. The key is added afterwards, through the ring on this same settings page or through the `RUSTVY_API_KEY` variable. So the normal sight straight after installing is "the route was created, the key is not set yet", and that is what the page says. **The default model is switched only if you never chose one.** Two conditions, both required: the route was absent and was created by this same mount, and the `agent-default-model` row has no override at the profile layer. The second is the only reliable line between a clean install and a deliberate choice — if you ever picked the model yourself, the row is left alone and the composer stays on your choice. When the choice cannot be checked, the plugin writes nothing: an overwritten model cannot be recovered from it, and a guess costs more here than doing nothing. **What the settings page says.** The outcome of the route creation is a conditional line at the top of the Rustvy section, above the uptime box, drawn only when there is something to say: | Outcome | Line | |---|---| | the route was created by this mount | «Маршрут rustvy создан этим монтированием: N моделей», plus a separate one about the key | | the route was already there | nothing — except the key line when no key is set | | it could not be created | «Маршрут rustvy не создан: …» with the reason and the next action | | the default model was switched | «Модель по умолчанию переключена с X на Y — Z» | | not switched because you had chosen it | one calm line explaining that | | not switched because the route was already there | nothing | The section order does not move for it: the line is conditional, and the ordering decision was about sections. In the steady state nothing renders, so uptime stays first exactly as it was placed; when a line does render the install is broken or was just bootstrapped, and that outranks the order. There is no button on that line, and that is a decision rather than an omission: the bootstrap runs at mount only and there is no retry endpoint, so a button could only re-read a state that re-runs nothing — a control labelled "retry" that does not retry. The real action is a reload of the window, and the line names it in words, differently per reason. About the key the page claims exactly what it knows. The host asks the same credentials mechanism the adapter uses rather than the process environment, and it counts the ring: a key added through the ring makes the provider usable while `RUSTVY_API_KEY` in the environment stays empty. «Ключ ещё не задан» is printed only when neither resolves; when the host could not determine it, the page points at both places and claims nothing. The two phrasings differ by exactly one assertion, and a check in the suite keeps them from merging, because merging them would put "not set yet" back on a machine that works. The value is recomputed on every state request, so a key added through the ring after the page loaded removes the line without a reload — otherwise the ring below would show the new key while the line above claimed there is none. The page also says where the model list came from when the origin did not answer and the route was created from the local catalog copy: such a list is of unknown age and is worth re-reading once the network is back. «Обновить каталог и иконки» is not the remedy here — the mirror is built from that same origin, and the host already tried to refresh it while creating the route. ### Using it Open **Settings → Rustvy**. The page shows what the mirror holds, what the provider serves, and the exact drift between them. **Обновить каталог и иконки** refreshes both halves; **Записать список моделей** writes the generated list. Nothing is written without one of those, unless you ask for it in the settings. The **«API-ключи»** section on the same page holds the ring: the **«+»** button opens an inline "label + value" form, and each key's row offers a value replacement, a rename, making it the default, a test and a delete. None of that is required, though — the switch in the composer's tool row does the same thing for whichever chat is open, without leaving the conversation. ## Settings **Settings → Rustvy** Top to bottom the page runs: the switches and fields, then the **«Аптайм моделей»** box, **«Модели провайдера»**, **«Ключи на несколько чатов»**, **«Сбор данных Rustvy»** and, at the very bottom, **«Зеркало каталога»** with its addresses and files. Uptime comes first because it is the only block that answers the question the page is opened with — is the model about to be picked alive — and the mirror comes last, next to the warning about a failed catalog read that only it is the subject of. Inside the **«Зеркало каталога»** box, beside the other network facts, lives the plugin's proxy: a family selector **«Тип прокси»** with two positions — **HTTP(S)** and **SOCKS** — an address field, a switch, «Записать» and «Проверить прокси». The address carries no scheme: the field takes `login:password@host:port` and the family is chosen only by the selector, so the string and the selector cannot disagree. `HTTP(S)` means "a proxy that speaks HTTP", and in this release the connection to it is always plaintext: automatic transport discovery is not implemented. The client is already written to print the transport sentence the moment the host half starts publishing it — until then that sentence says nothing rather than drawing a dash. `SOCKS` means SOCKS5, and SOCKS4 is refused loudly with text pointing at the selector. The family is never inferred from the address: a password says nothing about encryption of the connection to the proxy — an authenticated HTTP proxy is the common case in the wild — and nothing about the SOCKS handshake version, where a SOCKS5 server without auth still rejects a SOCKS4 greeting. Changing position rebuilds the transport even with the same `host:port`, so the selector writes immediately rather than waiting for «Записать». The status line above them says one of four things — an installed proxy is shown **masked**, a switched-off one reads «выключен», an enabled one that failed to install carries the host's own words in the warning colour, and a host that publishes no proxy at all gets a dash. The field is the only place on the page where the address is visible in full, password included: it has to show exactly what is stored even when the string cannot be parsed, because a field that quietly corrected the value would disagree with the file invisibly. Unlike every other field on the page, this one does not save when it loses focus, and that is not an inconsistency: «Проверить прокси» is a button beside the field, pressing a button blurs it, and saving on blur would mean the probe first wrote the address and then reported that it had written nothing. Only «Записать» writes. The probe answers with three different sentences rather than two states: **any** provider response, 502 and 503 included, means the proxy works, because the request went through it and the status is information rather than failure; only no response at all counts as not carrying it — refused, timeout, DNS; and when the string could not be parsed, no request was sent at all, which is a mistake in the text and not in the proxy. A failure of the probe itself is reported separately, as the fourth case, because it sends the user to his network or to the plugin rather than to his text. The sentence names which address was probed — the typed one, not yet written, or the stored one — since "the proxy works" about a string the user never applied needs a visible subject. The probe saves nothing and switches nothing on, and the page says so when the switch is off: "the test passed" beside a proxy that is off is exactly the confusion that gets reported as a bug. Marks: | Control | Default | Meaning | |---|---|---| | Показывать иконки моделей | on | master switch for the marks; off removes every mark and leaves the model list alone | | Иконка в кнопке выбранной модели | on | also mark the composer's model button, not just the menu rows; it also takes the uptime badge off that button | | Прятать логотипы других скинов | on | hide a skin's own lockup in a row this plugin marked | | Показывать аптайм моделей | on | the «Аптайм моделей» box on this page and the state badge in the model picker and on the selected-model button; off reads the collector's mirror at all | | Размер значка, px | 16 | 10–32 | Model list: | Control | Default | Meaning | |---|---|---| | Держать список моделей маршрута актуальным | on | master switch; off leaves the route's YAML untouched | | Записывать список | при нажатии «Обновить» | `только отдельной кнопкой` never writes on its own · `при нажатии «Обновить»` writes when the refresh button is pressed · `сам, при каждом обновлении` also writes on the daily background refresh | | Название модели | как на сайте провайдера | the provider's display name, or the raw id | | Предлагать уровень «без рассуждений» | on | add an `off` level to a reasoning model that does not declare one | | Оставлять модели, которые провайдер больше не отдаёт | off | keep an installed model the endpoint no longer serves, for a listing that is momentarily wrong rather than genuinely retired | | Маршрут провайдера | `rustvy` | which key under `providers` to write | | Строка профиля | `llm-pi-ai` | which loader row holds it | | Сайт провайдера | `https://rustvy.xyz` | where the catalog and the logos are read from | The default write mode is deliberately the middle one: a button press is a decision, a background expiry is not, and rewriting a live profile's loader config while nobody asked is worth an opt-in. Every address and file the plugin touches is on the page — the site, the catalog page and the models endpoint as links that open in a new tab, and the mirror, settings, backup and profile-patch paths as copyable text. **Показать блок для ручной правки** renders the exact YAML the automatic write would produce, so anyone who would rather paste it into `cordis.patch.yml` themselves is not transcribing a summary. One field has no control because it belongs in a profile's YAML rather than a form: `modelZones` (default `["standard"]`), the catalog zones admitted when the endpoint is unreachable and the mirror decides membership instead. Keys: | Control | Default | Meaning | |---|---|---| | Ключи на несколько чатов | on | master switch for the ring; off applies no substitution at all, and every request goes out on the profile's route | Turning it off deletes nothing: the key routes stay in the profile's configuration, the values stay in the credential store and the chats' picks stay in their own file, so turning it back on resumes exactly where it left off. The switch lives in the «API-ключи» section rather than beside the mark and model-list switches, because it belongs to the ring — and that section is also where the page says why adding a key is unavailable, when the host offers no service to add one with. Error collection: | Control | Default | Meaning | |---|---|---| | Отправлять успешные запросы | on | the denominator of the uptime percentage. Successes are aggregated into a window (1 minute by default) and leave as one report rather than one per request: aggregation removes 21% of the requests compared with sending each, where the former 15-minute window removed 86% — the window became comparable to the gap between successes, and the delay in the percentage was the price of that saving. An empty window is never sent at all, so an idle machine pays nothing — a real zero, not a rounding. Switching it off stops sending but not counting: the counter keeps closing folded failures and keeps answering whether the observer is alive | | Собирать ошибки rustvy | on | master switch for the collector: one line per infrastructure fault of the provider goes to the uptime collector. Off sends nothing, and the queue already on disk stays on disk | Both switches live in the **«Сбор данных Rustvy»** block — successes above errors, as the user asked — because they are the only two settings on the page that send anything off the machine. The block was retitled from «Сбор ошибок Rustvy»: a caption promising only error collection would describe its contents falsely, and the caption is what a user reads to decide whether to open the block. They were not moved up into the display-settings block, which is entirely about pixels — marks, the trigger icon, size — where a data-collection switch would sit beside settings that change nothing but how things look. Under the successes switch is one short explanation of the cost, because the user asked about traffic specifically: what one report weighs and what a working day comes to. The figures in it are measured on a real document rather than taken from an estimate, and they travel with the conditions they were measured under; beside them appears this install's own total — how many windows and bytes have left since it started — whenever the host publishes it. A number the state does not carry is not drawn at all: an invented traffic estimate on screen is worse than no estimate. There are no send or retry buttons in the block and there will be none: the window flushes on its own timer and there is no endpoint for flushing it sooner, so a button labelled "send now" could only re-read a state that re-runs nothing — a control labelled "retry" that does not retry. As its own line, and only when the counter is non-zero, the block reports «N окон потеряно» — how many success windows this install formed and never delivered to the collector; a zero loss is not drawn, because a line that stands empty for most installs for their whole life teaches the reader to skip the block. What leaves, what never does and when collection fires is written in [Error collection and uptime](#error-collection-and-uptime) below and in `docs/error-report.md` — the disclosure did not disappear with the text on the page, it moved somewhere it can be read in full. The state of the collection — the collector's address, the anonymous identifier, the sent, queued and dropped counters, the last answer, the last send error and the failures by kind — is still served by the host's own loopback routes, `/dsh-rustvy/state` and `/dsh-rustvy/errors`, and that is where it is read, not in the interface. The address — `uptimeEndpoint`, `http://185.75.189.247:3789/api/report` by default — has no field of its own: it is repointed without a release by the `DSH_RUSTVY_UPTIME_ENDPOINT` environment variable or by hand in `\.dsh-rustvy\config.json`. Stored in `\.dsh-rustvy\config.json`. Five environment variables override it, for a deployment that points the mirror and the collector elsewhere or wants the plugin mounted but inert, mounted but silent, or mounted without the uptime panel: `DSH_RUSTVY_ORIGIN` and `DSH_RUSTVY_DISABLED=1`, `DSH_RUSTVY_UPTIME_ENDPOINT`, `DSH_RUSTVY_NO_ERRORS=1` and `DSH_RUSTVY_NO_UPTIME=1`. The first repoints the provider's site, the second disables the plugin whole. The third repoints the uptime collector and overrides both the default and `config.json`: only an address that parses as `http`/`https` is accepted, trailing slashes are stripped, and an unusable value is ignored, leaving the setting in force. The fourth and the fifth, each set to `1` or `true`, force error collection and the uptime view off regardless of the setting — the same device by which `DSH_RUSTVY_DISABLED` silences the marks. ## Where the pictures come from Nothing is embedded in this package. The host half mirrors two public things from the provider's site into the profile, and the browser half reads them from loopback: 1. **the model catalog** — the site renders its model table from a JSON island in its own page (`