Skip to content
dsh-market Browse plugins GitHub 中文

Ebony-Vinyl/dsh-our-free-model

Adds free model lanes to DeepSeek Harness — DeepSeek V4.1 Flash, Kimi K3 and others — with no login, sign-up or API key, plus a local OpenAI-compatible gateway, fallback routing and a usage dashboard.

Stars ★ 23357 Category Models & Providers Listed 2026-10-17

Install

Inside DeepSeek Harness, with dsh-market

dsh plugin --profile web add dshmarket

Or from the command line

dsh plugin --profile web add github:Ebony-Vinyl/dsh-our-free-model

Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.

README

dsh-our-free-model

简体中文 | English

All you do is install this plugin in dsh — no login, no sign-up, no API key, no other step of any kind. The frontier models are simply there, DeepSeek V4.1 Flash and Kimi K3 among them. Completely free, with no usage cap.

你只需在 dsh 里装上这个插件,无需登录、注册、填 API Key 或任何其它操作,就能用上包括 DeepSeek V4.1 Flash、Kimi K3 在内的前沿模型——完全免费,不限量。

The roster follows upstream, availability is measured from your own network egress, the thinking-effort control sends a real budget instead of a prompt hint, and a local OpenAI-compatible forward port comes included.

It mounts as a pure plugin: no core changes, no build step, zero dependencies.


Community


Highlights

  • Nothing to configure — install, restart, pick a model. No account, no key, no quota dashboard to register on.
  • The upstreams are named — the free lane is OpenCode's Zen gateway at https://opencode.ai, and the Kilo channel is Kilo AI's public gateway at https://kilo.ai; both are dialled directly, with no third party relaying your traffic. Who serves your requests, and where your data goes, is spelled out in Where the models come from and the Disclaimer.
  • A roster that tracks upstream — model set, context length and capabilities are re-fetched on every refresh rather than frozen into the plugin.
  • The picker offers only what actually answers — a model the upstream listing names but the gateway refuses to route outright (Model is unavailable, a 404 for that id) leaves the dropdown and stays visible in the settings page with its refusal recorded. Everything that is not a statement about the model keeps its model reachable: a 5xx from the gateway, a 429 quota window, a timeout or a dropped connection. Region-gated ones move to their own region-limited group. If a whole round refuses everything, nothing is hidden: the picker never goes empty.
  • Announcement center with live push — the repository owner edits one JSON file and pushes; every installation receives it within one poll cycle. Bodies are HTML rendered through a strict allowlist; urgent items open a full-screen modal; optional OS-level notifications.
  • In-app upgrades — one click in the settings page: download → SHA-256 verification → backup → atomic replace → read-back verification → hot reload, with automatic rollback if any step fails.
  • Hot reload — upgrades and code changes take effect immediately, no app restart; also available as a manual button and an optional file watcher.
  • Region-aware, per egress — models gated by geography are separated into their own region-limited group instead of failing mid-turn. Switch your network exit and the next probe reclassifies them automatically.
  • The body decides what it is, not the header — under load this gateway answers 200 with a JSON Content-Type over a perfectly ordinary SSE frame stream. The plugin sniffs the first bytes and replays them into the stream, so the turn keeps streaming instead of being thrown away — and a working model is never demoted to unavailable because one header lied.
  • Thinking effort that actually binds — two kinds of model, two kinds of control. A model that declares thinking levels (DeepSeek V4.1 Flash, GLM 5.3 Flash among them) gets its own level menu (Off / Low / High / Max …), and the selected level is sent as-is in the upstream reasoning_effort field — which this gateway validates, so a value outside the set is a 400. A model with no level menu (MiMo V2.6 among them) gets the budget ladder: Light / Balanced / Deep map to output-token budgets of 2 048 / 8 192 / the model's full capacity, and a model that cannot switch thinking off gets the whole ladder doubled to 4 096 / 16 384 / capacity, because there thinking and the visible answer compete for the one ceiling. Both are recorded per call, and every model card in the settings page prints either the levels or the number it will actually send (see Thinking effort).
  • Works without a browser UI — only llm is a hard dependency, so the plugin activates on a headless composition such as dsh-tui and still serves its models. The dashboard half lives on its own fiber and mounts itself when webServer appears, so a composition that loads plugins before its HTTP server exists still gets its settings page. Availability probing and the background loops run on plain timers.
  • Usage dashboard, local only — token heatmap, cumulative curve by total or per model, output speed and time-to-first-token sampled per call. Nothing is uploaded.
  • OpenAI-compatible forward port — expose these models to any other local tool through a base URL plus a generated API key.
  • EAC lane — a co-paid lane for DSHEAC AIO and kernel-declared desktop, tauri, web, or web-desktop profiles, with models prefixed EAC. Its credential is sealed; host admission and server-side GitHub authorization (sign in and star the repository) remain separate requirements. Command-line profile arguments alone do not grant access.
  • The Kilo channel (keyless free pool) — the free model pool of Kilo AI's public gateway (the listing's isFree slice, fetched live, including the kilo-auto/free auto-router) is built in; no account, no key. Model cards carry a Kilo badge. Thinking effort works like the EAC lane: the model's own level menu (Off / Low / Medium / High, default High), sent for real through the gateway's unified reasoning parameter — Off was verified family by family to zero out thinking (nemotron, ling, dots, poolside, apodex, cohere); the stepfun and liquid endpoints refuse to disable thinking (HTTP 400) and the two auto-routers ignore it, so those models' menus omit the Off rung. The pool is the upstream's free offer, and its model cards state that prompts may be logged to improve their services — never send sensitive content; see the Disclaimer.
  • The aggregate lane — your own key, 75 providers — a new settings tab brings third-party endpoints into the same roster: the registry ships 75 providers (NVIDIA NIM, OpenRouter, Groq, Cerebras, SambaNova, Gemini, DeepSeek, Mistral, Together, Fireworks, SiliconFlow, Nebius, Chutes, Hugging Face, Cloudflare, Z.ai, Kimi, MiniMax, QwenCloud/Alibaba, Bedrock, Vertex, Vercel AI Gateway, Azure OpenAI, plus the no-account Ollama, LM Studio and llama.cpp). Configuring a provider (a key for the ones that need one; the local no-account ones need none) turns each of its models into a provider/model row in the picker and on the local gateway. Keys stay in the local 0600 settings.json; the API echoes only hasKey and a …abcd fingerprint. Per provider: enable/disable, Test (a listing probe — it verifies a key without spending a generation), Discover, a base-URL override, a model-list override and Remove. OAuth/ADC providers are listed but marked not drivable here. OpenCode's keyless free pool is deliberately not a registry row: it requires a per-request minted session id, the v2 client User-Agent and a set of official tool declarations, which this lane's generic transport has nowhere to carry — listing it would only add a picker row that can never answer, and those ten models are served natively by the plugin's own free lane.
  • Token savers — three things: tool-output compaction (RTK) rewrites tool_result content in place before the request is sent (git diff / status / log, grep, find, ls, tree, build logs, numbered file dumps, search listings, duplicate log lines) — measured live, a 40 KB git diff lost 90% of its bytes and the model still answered about it; prompt shaping offers the caveman and ponytail families, every level off by default; and five local answers that never call a provider. The settings page reports bytes before/after, percent saved, blocks compacted and calls measured, from the same stats rows the usage charts read.
  • Routing (fallback chains) — chain models from any lane into one named chain that appears in the picker and on the gateway as the virtual model combo:<name>. fallback always starts at the head; round-robin rotates the starting point and stays there for N turns. A link that fails before any answer delta is discarded whole, so the switch is invisible to the caller — and reasoning deltas deliberately do not count as content, so "thought for a minute, then hit a quota wall" is the case failover exists for.
  • The local gateway speaks Anthropic natively — new POST /v1/messages (streaming and non-streaming, with thinking/tool_use blocks, a real stop_reason and an in-stream error event when a turn fails after the headers went out) and POST /v1/messages/count_tokens (a local estimate — the plugin ships no tokenizer); /v1/models now advertises every lane, the aggregate rows and the chains included. The gateway page's new Connect a client section generates copy-ready recipes — Claude Code, Codex config.toml, the OpenAI and Anthropic SDKs, curl, and a LAN variant when the relay is up — from the port that actually bound and the key that is actually required.
  • Eleven tabs beside the old six — Aggregate, Token savers, Routing, Plugin upgrade and Recommended projects join Free models, EAC models, Free channels, Dashboard, Request log and Gateway; the motion pass (card rise on mount, page fade on tab switch, an animated savings meter, hover lifts) respects prefers-reduced-motion and collapses to one column at panel width. The Recommended-projects page carries the three companion repositories — EAC-Desktop, EAC-Pack and EAC-design-world — with a description, a direct link and a star button each, and their live star, fork and last-push numbers read from GitHub (absent, never invented, when GitHub cannot be reached).
  • Clean names in the UI — no mojibake, no upstream vendor strings leaking into your model picker.

What you get

Composer model picker

Group Contents
Our Free Model Models usable from your current network exit
Our Free Model · region-limited Models the upstream refuses for this region, kept visible but separated

A model the gateway names but refuses to route appears in neither group. It stays listed under Not in the picker in the settings page, with the refusal and the probe time, and returns to the picker by itself as soon as a probe gets through.

Settings page — Settings → Our Free Model, seven sections:

  1. Model roster — per-model availability, vision vs text-only, context window, max output, the output ceiling each effort rung really sends, measured time-to-first-token, and an on-demand single-call benchmark.
  2. EAC lane authorization — one-click GitHub sign-in (the browser does the work; nothing to paste), the signed-in name and star verdict, a re-check button and sign-out; model cards carry a lock until authorized. The free lane is unaffected.
  3. Announcement center — the owner-pushed feed: unread counter, urgency badges, mark-read (single/all), check-now button, OS-notification toggle. Bodies render HTML through the allowlist.
  4. Usage board — headline counters, a 17-week token heatmap, a cumulative curve switchable between tokens and request counts and between total and any single model, speed sparklines, and a per-model table.
  5. Local forward — enable/disable, bind host and port, copy base URL, show / copy / rotate the API key, and a ready-to-run curl example.
  6. Plugin settings — master switch, whether region-limited models are exposed, probe interval, default output ceiling, plus the detected egress IP and country.
  7. Plugin upgrade — installed/latest version, check for updates, one-click upgrade with progress and failure reasons, last-upgrade history, hot-reload button and file-watcher switch.

First-run announcement — a five-page walkthrough (preamble, model roster, how to use, what it does, news & upgrades) that acknowledges once and never reappears until the copy version is bumped.

Install

⚠️ Do not install this plugin from npm.

The dsh-our-free-model package on npm is not published by this repository. It is a third-party repackaging whose contents do not match this tree: index.js and client.js were modified, and it ships a src/channel-visibility.js that has never existed here. Installing it gets you a build this project did not review, and you lose the signed-manifest verification and in-app upgrades that this repository provides.

Install only the ways documented below: a release package from this repository, or a local source directory. For any same-named package from another channel, this repository is the source of truth.

Command line (plain dsh web)

dsh plugin --profile web add /absolute/path/to/dsh-our-free-model

Restart the app once. --profile should name the profile you actually use.

DSHEAC AIO / desktop builds — read this first

Some DSHEAC AIO and older desktop packs run a profile gate before starting the web app and restrict symlinks or directory junctions. A host with that check may report:

PROFILE_UPGRADE_REQUIRED: offline dependency migration is not yet available

When this gate applies, avoid link: dependencies and junctions. Use the in-app plugin manager or the local tarball workflow below. Follow the actual host's logs and installation contract rather than assuming every desktop shell shares this gate.

To install manually as a real directory, use a trusted local tarball:

  1. Create <DSH_HOME>/profiles/<profile>/vendor/. From the reviewed repository, run npm pack --ignore-scripts --pack-destination "<DSH_HOME>/profiles/<profile>/vendor", or place an official same-version release tarball from this repository there. Local packing does not publish to npm.
  2. Add "dsh-our-free-model": "file:./vendor/dsh-our-free-model-2.5.3.tgz" to the profile's package.json dependencies, then use the host's installation flow to resolve it. Keep the filename version aligned with this repository's package.json; do not substitute a same-named npm registry version.
  3. Append "dsh-our-free-model" to dsh.profile.bundles.

When the package manager owns the installed bytes, set distribution: 'managed' in the bundle config or plugin settings.json. The package manager then handles updates; in-app upgrades, announcement polling and hot reload stand down while model access remains available. For later updates, replace the trusted tarball and update its dependency path so the old lockfile cannot restore an older copy.

If copying release files manually, copy the entire package, including adapter/, src/, locale/, and vendor/channel-pack/'s pack.js, WASM, LICENSE and NOTICE. Copying only entry scripts leaves channel loading incomplete.

Do not also add an entry to cordis.patch.yml. A bundle referenced from dsh.profile.bundles already applies its own patch layer, and registering it twice fails with duplicate loader entry id: our-free-model.

Integration packs (managed installation)

When the plugin arrives through an integration pack (an EAC pack, Mojobox, …), the pack owns update timing and bytes: install with the bundle config distribution: 'managed' (or write the same field into settings.json), and the in-app updater, the announcement feed and the hot reload all stand down — two writers to one installed directory corrupt it; the model lane is unaffected. Acceptance lives in scripts/offline-test.mjs; the catalog-ready records (manifest 0.15 / provenance / license / integrity) are under catalog/.

Verify before launching the desktop app

Run the desktop host's own gate against your profile instead of blind-retrying:

node -e "
const g = require('<APP_ROOT>/sidecar/dist/lib/profile-upgrade.js');
const app = '<APP_ROOT>', profile = '<DSH_HOME>/profiles/<profile>';
console.log(g.planProfileUpgrade(app, profile));
g.assertProfileStartup(app, profile);
console.log('startup gate: PASS');
"

Expect status: 'compatible', an empty mismatches array, then PASS. To check bundle composition only, without booting:

DSH_HOME=<DSH_HOME> dsh --profile <profile> --dump-config | grep our-free-model

You should see exactly one id: our-free-model entry.

Install failure: ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF

This is a pnpm state problem in the target profile, not the plugin repo (it fires before the plugin is even downloaded): the profile's existing node_modules was created by an older pnpm, and after a dsh update the bundled pnpm refuses to keep using it. Close dsh, delete the profile's node_modules and pnpm-lock.yaml to let pnpm rebuild them, then install again:

rd /s /q "%DSH_HOME%\profiles\web\node_modules"
del "%DSH_HOME%\profiles\web\pnpm-lock.yaml"

The accompanying Ignoring broken lockfile warnings disappear with the rebuild.

Desktop installers (macOS, community build)

The dsh plugin itself needs no build. The desktop installer (the Our Free Model desktop shell) is officially published for Windows only; the macOS installer is a community build. The source changes are listed in PR #421 and are fully auditable, line by line. A ready-made Apple Silicon (arm64) build is available from that PR and its community release, or you can build it yourself on a Mac (npm --prefix packages/desktop run build; the full pipeline lives in .github/workflows/build-macos.yml).

Verify the checksum after downloading (SHA256 is listed in the PR / release):

shasum -a 256 ~/Downloads/Our.Free.Model_<version>_aarch64.dmg

"This app is damaged" or won't open?

The community build is not signed or notarized with an Apple Developer account, so Gatekeeper blocks first launch. The file is not actually damaged — pick any one of these:

  1. Right-click open: in Finder, Control-click the app icon, choose Open, then confirm Open again in the dialog.

  2. Remove the quarantine attribute (Terminal):

    xattr -cr /Applications/Our\ Free\ Model.app
    
  3. If it still says damaged, remove quarantine with sudo (newer macOS versions are stricter about unsigned apps):

    sudo xattr -rd com.apple.quarantine "/Applications/Our Free Model.app"
    
  4. Allow it in System Settings: open System Settings → Privacy & Security and click Open Anyway if the button is offered.

Notes:

  • The current community build is Apple Silicon (arm64); Intel Macs should build from source.
  • The bundle ships its own Node runtime and standalone service — no login, no API key.
  • The package is not signed or notarized by Apple. Trust it based on the auditable source change and the reproducible CI build record above, nothing else.

Usage

Pick a model. Open the model selector in the composer and choose anything under Our Free Model. The selection is durable per session.

Change thinking depth. The same menu exposes Effort with Light, Balanced and Deep. Higher levels spend more of the output budget on deliberation; the ceiling is enforced on the request, so the difference is measurable rather than cosmetic. It is one ceiling shared by deliberation and the visible answer, which is why a model that cannot switch thinking off gets the whole ladder doubled (the model cards in Settings → Our Free Model print the number each rung will really send). If answers keep getting cut off, move to Deep, or raise the per-call output ceiling in the settings.

Serve other local tools. Settings → Our Free Model → Local forward, enable it, then copy the base URL and generate a key. Supported routes:

GET  /v1/models
POST /v1/chat/completions        streaming and non-streaming
POST /v1/responses
POST /v1/messages                streaming and non-streaming (Anthropic Messages)
POST /v1/messages/count_tokens   local estimate; the plugin ships no tokenizer

If another program holds the port, the listener does not die: it retries the same port for a few rounds (a listener that just closed, or a portproxy rule that was just removed, frees its port within a few hundred milliseconds), then walks to the next free port and says so on the settings page — requested 18899 is not available, listening on 18900 — and the port written back into the settings is the real one. On Windows the most common owner is a netsh interface portproxy rule (served by IP Helper): its listener on 0.0.0.0 makes the loopback bind fail with EACCES rather than EADDRINUSE. netsh interface portproxy show all lists the rules and netsh interface portproxy reset clears them.

The forward endpoint streams in full: besides the data: frames, a lane that is thinking gets periodic SSE comment frames (a : line), so a client's idle timeout cannot read "the upstream is still thinking" as "the socket is dead". Thinking is recognised under reasoning, reasoning_content and reasoning_text — a gateway that repeats one thought under two of them is counted once — and the reasoning_details array as well. A model whose thinking never streams upstream still sends no reasoning frames here; see Known limitations.

Serve other devices on your network. The same panel carries a Network access section. It is off by default; switched on, the relay binds a routable address (0.0.0.0 by default) and demands a key of its own — separate from the local key, so a leak on either side costs a rotation on that side only and the tools already wired to the local port never notice. The relay re-issues to the local listener, so the model roster, streaming and error semantics are the same ones the local port serves. A port of 0 means "pick one" (18899 is often already taken on a machine that runs something else); the panel then shows the port and the network address it settled on. While it is on, anyone who can reach this machine can spend its free quota with that key — enable it only on a network you trust, and narrow the sources with a firewall if you can.

Send requests through a proxy outlet. Settings → Our Free Model → Egress proxy. Optional and off by default — with it off, requests go direct. Two modes:

  • Subscription mode: paste a Clash/V2Ray subscription URL; the plugin spawns mihomo locally (auto-detected — e.g. Clash Verge's verge-mihomo.exe — or set the path yourself) with a built-in url-test group that re-measures every five minutes and sends traffic to the fastest node, health-checking dead nodes out. The subscription URL is treated as a credential — the path of a subscription link is its token — so it lives at the same standard as the two forward keys: kept in the local settings file, never sent with a request, and never echoed in a settings payload. The panel shows the masked host only (https://host/…); "Show" and "Copy" fetch the value from the settings page itself (GET /egress/url), an empty input means "keep the stored address", and "Clear" removes it and takes the outlet down with it.
  • Single-proxy mode: type an http:// / https:// / socks5:// / socks5h:// proxy address and every handled request dials through it.

At rest. mihomo has to read that address out of a config file (the plugin writes a throwaway mihomo.yaml into its data directory and spawns mihomo from there), so the on-disk copy stays plain text. What this treatment removes is the payload echo, the log line and the shoulder — not another process on the same machine.

Only three kinds of traffic are taken over: model inference, the model listing fetch, and the egress IP probe; announcements, upgrades, and the EAC lane stay direct so the subscription identity never mixes into the release channel.

Scope. The outlet serves this plugin's opencode traffic and nothing else on this machine. The mihomo it spawns runs from a throwaway config of the plugin's own: bind-address: 127.0.0.1 with allow-lan: false, no tun: section, no system proxy, no system-level proxy registration and no virtual adapter — the listener is a temporary free loopback port. Your browser, shell tools and other IDE extensions never go through it; only requests this plugin sends to the opencode gateway are rerouted. Single-proxy mode works the same way: the rewrite happens inside the plugin.

Live status. While the outlet is on, the settings page adds three lines below it: the current outlet (masked host · mode), the best node (the node url-test picked, with the delay that won it the rank), and opencode access (the last model-listing round trip, in seconds). Node and latency are readings, refreshed every 15 seconds by asking mihomo's own controller; when a reading is missing the line says "measuring…" and outbound traffic is unaffected.

What it can and cannot buy you: per-IP rate limits and region gates move with the outlet; per-session rate limits and fingerprint gates do not care which outlet you use. Toggling the outlet re-probes on its own — a cold outlet needs a few seconds before it carries traffic, and the plugin waits for the new exit to answer before regrouping, so region-gated models follow the new exit. Reprobe (below) does the same on demand.

Re-check geography. 重新探测可用性 (Reprobe) re-runs availability against your current exit. Toggling a VPN and re-probing moves region-gated models between the two groups on its own.

Receive announcements. Everything is automatic: after the owner pushes, a running installation picks the item up within one poll cycle (30 minutes by default, or immediately via Check for new announcements). Regular items raise a toast, urgent items open a full-screen modal, and both land in the announcement center with an unread marker. Enable OS notifications there to also get system-level toasts.

Upgrade the plugin. Settings → Our Free Model → Plugin upgrade → Check for updates → Upgrade now. The whole flow runs inside the app (download → verify → backup → replace → hot reload); no reinstall, no restart. A failed upgrade restores the previous version and reports why.

The aggregate lane, token savers, routing and the local gateway

The aggregate lane: bring your own key for 75 providers

The Aggregate tab brings third-party endpoints into the same roster. The provider registry ships 75 entries (src/providers/registry.json, categorised free | free-tier | apikey | oauth | local): NVIDIA NIM, OpenRouter, Groq, Cerebras, SambaNova, Gemini, DeepSeek, Mistral, Together, Fireworks, SiliconFlow, Nebius, Chutes, Hugging Face, Cloudflare, Z.ai, Kimi, MiniMax, QwenCloud/Alibaba, Bedrock, Vertex, Vercel AI Gateway, Azure OpenAI, and the local no-account ones — Ollama, LM Studio and llama.cpp. The data comes from free-claude-code's provider catalogue and 9router's provider registry.

Once a provider is configured (a key for the ones that need one; the local no-account ones need none), each of its models becomes a catalog row named provider/model: selectable in the model picker and dialable through the local gateway. A provider's own wire format decides how it is served — OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages (the last two for the providers that only speak those).

Keys live only in the plugin's own 0600 settings.json, and the API never echoes them: the page shows hasKey plus a …abcd fingerprint. An absent field keeps the stored key; an empty string clears it. Per provider the page offers enable/disable, a Test button, a Discover button, a base-URL override (needed for Azure, Cloudflare and Vertex, which have no usable default), a model-list override and Remove. Test is a listing probe — it verifies a key through the provider's model listing without spending a generation, and says so when the provider declares no listing. Providers whose auth is OAuth or ADC (Google ADC) are listed but marked not drivable here, because this lane cannot complete those flows.

Token savers: tool-output compaction, prompt shaping and local answers

Tool-output compaction (RTK) rewrites tool_result / role:"tool" content in place before the request is sent: git diff, git status, git log, grep, find, ls, tree, build and package-manager logs, numbered file dumps, Cursor search listings, and runs of duplicate log lines. Measured live: a 40 KB git diff lost 90% of its bytes and the model still answered about the diff. The safety rules: error tool results are never touched, bodies below a size floor are left alone, the replacement is never larger than the input, a filter that throws passes the original through, and the pass is idempotent. It applies to every lane, the local gateway included, and is on by default.

Prompt shaping offers two families with levels — caveman (terse replies: off/lite/full/ultra/wenyan-lite/wenyan/wenyan-ultra) and ponytail (YAGNI-minimal replies: off/lite/full/ultra). They change how the assistant talks, so every level starts off. Injection is idempotent and format-aware (a system message on chat, Anthropic's system including the block-array form and inserted before the last cache_control, instructions/input item on Responses), and it is reversible.

The savings dashboard shows bytes before → after, bytes saved, percent, blocks compacted and the number of calls measured — read from the same stats rows the usage charts use.

Five local answers (ported from free-claude-code, each individually switchable, all on by default) never call a provider: a quota probe (max_tokens:1 plus "quota" → Quota check passed.), shell command-prefix detection (<policy_spec> plus Command: → the prefix, refusing command injection), session-title generation (→ Conversation), suggestion mode (→ the empty string) and filepath extraction (→ a <filepaths> block). The local gateway answers them in-process.

Routing: named fallback chains (combos)

A named, ordered list of model ids across every lane, which appears in the picker and on the gateway as combo:<name> — a virtual model. Two strategies: fallback (always start at the head) and round-robin (rotate the starting point, sticky for N turns). The chain walks its links until one produces output; a link that fails before any content delta is discarded whole, so the switch is invisible to the caller. Reasoning deltas deliberately do not count as content — "thought for a minute, then hit a quota wall" is the highest-value case to fail over. Once an answer delta has been delivered, a later failure ends the turn normally (the single-model checkpoint recovery still gets its continuation attempt first). Capacities are the intersection of the links (a chain advertises vision only if every link has it, and the smallest context window), and a chain whose links are all currently unavailable is stored and marked not usable rather than silently dropped.

The local gateway: a native Anthropic surface and connect recipes

Two new endpoints: POST /v1/messages (streaming and non-streaming, with correct message_start / content_block_* / message_delta / message_stop framing, thinking blocks, tool_use blocks, a real stop_reason, and an in-stream error event when a turn fails after the headers went out) and POST /v1/messages/count_tokens (a documented local estimate — the plugin ships no tokenizer). Claude Code and the Anthropic SDKs can therefore point straight at the plugin. The existing /v1/chat/completions and /v1/responses are unchanged, and /v1/models now advertises every lane, the aggregate rows and the chains included. A new Connect a client section on the gateway page renders ready-to-copy recipes generated from the port that actually bound and the key that is actually required: Claude Code environment variables, Codex config.toml, the OpenAI SDK, the Anthropic SDK, curl, and a LAN variant when the relay is up.

One false success was fixed along the way: a turn whose only output was a tool call the caller never declared used to answer as a clean empty success; it now reports what happened, because the token saver's own [full diff: rtk git diff --no-compact] hint can make a model ask for a tool the caller does not have.

How it works

index.js      host half: adapter registration, catalog + availability probes,
              settings/stats stores, webServer API routes, forward lifecycle,
              announcement / upgrade / hot-reload wiring
adapter/      kernel seam: the only module in the package allowed to import
              @deepseek-ai/* (kernel.js: attribution User-Agent with a literal fallback)
src/adapter.js  structural LlmAdapter: providerInfo, listModels, resolveModel,
                prepareCall, stream, providerRetryPolicy
src/upstream.js gateway identity: credentials, session/request id minting,
                tool fingerprint, endpoint selection per wire
src/stream.js   three wire decoders (chat / messages / responses) normalised to
                harness StreamChunks, with disjoint token accounting
src/messages.js harness messages -> wire shapes, plus tool-pairing repair
src/effort.js   effort level -> output budget
src/forward.js  standalone OpenAI-compatible listener (plus the Anthropic Messages endpoints)
src/providers/registry.json aggregate-lane registry: 75 providers, category, auth, wire
                format and endpoints
src/providers/index.js registry access and provider/model composite id parsing
src/aggregate.js aggregate lane: catalog rows, credential resolution, key redaction,
                listing probes, per-wire posting
src/anthropic.js Anthropic Messages codec for /v1/messages, plus the local token estimate
src/token-saver/index.js compaction pipeline: prompt injection -> tool-output compaction,
                byte accounting, fail-open
src/token-saver/inject.js idempotent injection and stripping on the chat / messages /
                responses wires
src/token-saver/prompts.js the caveman and ponytail prompt families and their levels
src/token-saver/rtk/ tool-output filters: git diff/status/log, grep, find, ls, tree,
                build logs, numbered dumps, search listings, duplicate log lines
src/local-optimize.js the five local answers: quota, prefix, title, suggestion, filepath
src/combos.js fallback chains: virtual catalog rows, two strategies, intersected capacities
src/route/classify.js one failure vocabulary and cooldown policy for every lane
src/trust.js    request trust fence for the plugin's routes (connection bridge
                + structural fallback)
src/push.js     SSE push hub: arrivals, update availability, upgrade done
src/feed.js     remote announcement feed: multi-source fetch, validation, cache,
                arrival detection
src/updater.js  in-app upgrade: manifest validation, SHA-256 checks, backup,
                atomic replace, rollback
src/reload.js   self hot reload: mirrors the kernel HMR sequence (cache purge,
                re-import, re-register, rollback)
client.js       browser half: hand-written ModuleLoader bundle, no build step

Design decisions worth knowing:

  • One adapter, two provider routes. The harness groups the model picker strictly by provider route, and the catalog wire has no group/tag/badge field. Publishing a second route is therefore the only way to render a separate region-limited heading — and because the client drops empty groups, the two collapse into one automatically once geography stops blocking.
  • Structural adapter, no @deepseek-ai/dsh-llm import. The kernel never checks instanceof, so the adapter is duck-typed. This keeps the plugin from pinning itself to one kernel version and is what lets the same code run on both 0.1.5 and 0.1.7.
  • Own JSON store instead of the settings seam. The settings registration API differs between kernels; a private JSON store under DSH_HOME behaves identically on both and keeps the forward key in a 0600 file that never enters any shared settings document.
  • The token savers run on the single choke point, and they fail open. Prompt injection and tool-output compaction run after every other mutation has been applied — the fingerprint rename, the effort patch, the sealed lane's pacing hint — at payloadFor(), the one place both the adapter's own dispatch and the local gateway (which dials through the adapter) pass through, so every lane and every recovery segment is compacted. Every step fails open: a saver that throws is skipped, a filter that throws passes the original text through, an unrecognised body shape is left alone — a request must never fail over the thing that was supposed to make it cheaper.
  • A combo is a virtual catalog row, so failover is resolved in the adapter. A chain joins the merged catalog as channel:'combo' plus a list of real model ids, which is why the picker advertises it and the gateway dials it by name like any other row; the chain is expanded and advanced inside the adapter's runStream(), so neither caller has to know about failover.

Thinking effort: a level menu, or a budget ladder

Two kinds of model, two kinds of control.

A model that declares thinking levels (its listing carries reasoning_options with effort.values) gets a level menu: the selected level is sent as-is in the upstream reasoning_effort field, the same shape the EAC and Kilo lanes use. That is the 2026-10-17 re-probe result — the lane did ignore the field back on 2026-09-24, but it now validates it: a value outside the set answers 400 invalid_value naming the accepted set (none|minimal|low|medium|high|xhigh|max), and reasoning volume really does move with the level (deepseek-v4.1-flash-free, medians over three samples: none 0, low 793, high 700, xhigh 1405, max 1906). So the menu offers only the levels that model declares; a model whose set has no off rung (GLM 5.3 Flash, whose none does not actually stop it thinking) is not given one.

A model with no level menu (its listing offers only toggle or budget_tokens, or no reasoning_options at all) keeps the budget ladder: effort is a hard output-token ceiling, which does bind — recorded reasoning tokens rise monotonically with the level. Inventing a rung the upstream cannot express would claim a capability the model never had.

Window and output ceilings follow the 9router / models.dev convention (limit.context / limit.output, modalities.input); the upstream listing publishes an id and nothing else.

Three kernel behaviours that cost real debugging time

These are recorded here because they will bite any provider plugin:

  1. providerRetryPolicy() is stored verbatim. Neither kernel resolves it, and the backoff scheduler reads initialDelayMs / maxDelayMs / jitterRatio off the top level. Returning them nested under backoff yields undefined * 2ⁿ = NaN, and the durable session log rejects non-finite numbers — so a recoverable transient failure becomes an aborted turn. Return an already-resolved, flat policy.
  2. One interrupted tool call poisons the whole session. A tool call with no matching result replays as 400 invalid_request_error, after which every request in that session fails. The plugin repairs pairing before sending, on all three wires.
  3. A service you did not name in inject throws when read as a property, and ctx.get() answers undefined for one that is merely not provided yet. Both bit this round: after inject was reduced to ['llm'], typeof ctx.interval === 'function' threw cannot get property "timer" without inject (ctx.interval is a mixin over the timer service) and the plugin stopped activating on every composition. Switching that one to ctx.get('webServer') then answered undefined — not because the composition had no web server, but because plugins load before the browser half publishes it — so the settings routes were never registered and the dashboard had no data source behind a server that was busy serving the app. The shape that works is ctx.inject(deps, callback): give the services you need their own fiber and let it wait, instead of guessing once at load time.

Why the response is read by body shape, not by Content-Type

Under load this lane answers 200 with application/json over a normal SSE frame stream. Trusting the header meant await response.text() swallowed the live stream, JSON.parse failed, and the turn was lost — and because that error carried status: 200, the availability probe read the perfectly working model as unroutable and dropped it from the picker for a round. The first bytes (≤4 KB) are now sniffed and classified, then replayed into the stream, so nothing is buffered and no token arrives late; sniffBody in src/http.js is the only discriminator.

Why the speed panel can say —

An early build published 2 941 tok/s for a lane really doing ~40. The gateway was not at fault — a raw-read probe shows 64 frames spread over 5.6 s — but the numerator and the denominator described different intervals: one call billed 422 output tokens of which 291 were reasoning that never streamed a single frame, and the window began at the first visible token anyway. Dividing the whole completion by the seconds the answer text took is not a decode rate.

So a rate is published only over a window that can carry it: windowTokens() removes unstreamed reasoning tokens from the numerator, decodeWindow() rejects windows too short to time and rates too fast to be real, and both the panel and the per-model table sum tokens over sum seconds instead of averaging per-call ratios — one 1 ms window was enough to move a 26-call average by three orders of magnitude. A model whose answer lands in two frames therefore has no measurable output speed, and says so.

Automatic recovery after a long reasoning stream is cut (issue #12)

Every model enables one bounded recovery attempt by default, across the Chat Completions, Messages and Responses upstream protocols. It requires a first stream that delivered nonempty reasoning, no answer text or tool call, and then either reached EOF without a normal terminal frame, or received a normal stop terminal carrying reasoning only with no answer text — a turn the host would classify as an empty response. Cancellation, a normal ending that already delivered answer text or a tool call, an output ceiling and an explicit upstream error do not trigger recovery.

The plugin sends one new request containing the original input and the received reasoning as a text checkpoint, asking for the answer directly. This is not a native upstream resume, and already displayed reasoning is not replayed. Tools are disabled for the recovery request. A cut after answer text or a tool call has started is not recovered, to avoid duplicate content or execution.

To avoid another long reasoning pass, the continuation prompt asks for the conclusion first and at most 800 words. This is a prompt instruction, not a hard token limit, and the model may ignore it. Recovery is a degraded answer: an original request for a long or step-by-step analysis may be shortened.

One logical turn makes at most two physical requests: the original and one recovery. The default total deadline is 900 seconds (15 minutes); recovery gets at most 300 seconds, bounded by the remaining total time. Recovery output is capped at 8192 tokens and still respects the user's and model's ceilings. Known output tokens from the first segment are subtracted from the original budget; recovery does not start if fewer than 512 tokens remain. The checkpoint is limited to 131072 characters and must pass a conservative text-byte estimate of context capacity. That estimate does not count image tokens and is not an exact tokenizer check. Exceeding a limit stops recovery. Success requires a normal ending with answer text that is not only whitespace. Recovery failure returns STREAM_CUT, which the harness does not resend as a whole turn; user cancellation keeps aborted / ABORTED.

Set streamRecovery: false in the local settings.json to disable it. An object can also supply an enabled switch and lower numeric limits; limits cannot exceed the defaults. There is no model allowlist.

The local dashboard keeps physical requests and logical turns separate. Upstream requests and request failures count each HTTP request actually sent; conversation turns, turn failures and recovered turns count one adapter invocation by its final outcome. A first segment cut short and a successful continuation therefore show 2 upstream requests and 1 request failure, while remaining at 1 conversation turn with 0 turn failures and 1 recovered turn. Each segment still has its own sample with recovery identity, attempt index and noUsage markers. A shared recoveryId associates those markers to identify missing usage. Final usage sent to the harness sums only counts the upstream actually reported; if one segment has no usage report, this is not the full turn's token total. Reasoning checkpoints are not written to the statistics file.

Pre-upgrade history has only physical request records, so migrated turn and failure counts are estimates and the dashboard labels them. Turns recorded after the upgrade use the final outcome and are exact. Requests, turns and token totals are lifetime values; speed, first-frame and heatmap details use the locally retained history window.

One live MiMo V2.6 Flash · Deep request recovered successfully; an earlier attempt failed at the 480-second deadline. This does not establish live coverage of every model or guarantee completion of every long reasoning turn. The v1.3.1 records below describe behaviour before recovery was added.

Verification

Tested on Windows against the live upstream. The table is kept per release round, and each row says which way it was checked — only the rows marked live upstream or hands-on are behaviour a user sees in the interface.

Issue #12: verification status

Implementation, offline regression and live results are tracked separately in docs/issue-12-recovery.md. Recovery 99/99, truncation 35/35, 11 new fingerprint checks and typecheck have passed. Twelve checks use the real plugin over local HTTP to verify forward success, failures, client disconnection and stored statistics. The technical record carries the final full-suite result. In a live MiMo Deep run, the first stream reached natural EOF at 304.161 seconds; recovery took 32.777 seconds and produced 4264 characters of answer text, ending with stop, usage and [DONE]. There were two requests over 336.942 seconds. The first segment had no usage report, so the summary contains only known counts. The earlier failed 480-second attempt remains in the technical record. Real dsh UI and other models have not been individually accepted.

Historical: v1.3.2 (issues #11, #13, #19, #20, #21)

Item Fix Verification
#19 self-update trust chain Ed25519-signed manifests (public key pinned in the plugin, private key out of the repo), the update channel fully decoupled from feedUrl, manifest base restricted to relative paths updater-test: four new cases (signature, tamper, foreign key, feedUrl not followed); release-e2e's negative control now refuses at the signature
#

…

Content from the project README on GitHub ↗

Comments

Comments live in GitHub Discussions. Sign in with GitHub to post or react.