Repository navigation
Releases: artorias-zj/dsh-opencode-free-model
Release list
v2.3.0 — a verdict is an answer about one path, one model and one session
Ports the upstream free-lane corrections that apply to this plugin, from upstream v2.5.9..v2.7.2 (48 commits).
What was ported
One probe session per model. Quota on this lane is accounted per upstream session, so a round that shared one session spent the whole allowance on its first model and read every model after it back as 429 throttled — dropping models the egress could serve, over quota they never touched (upstream #561). Each probe now derives its session from the model id, which also keeps one model's probe session stable across rounds and restarts.
A verdict is only true of the path it was measured on. An egress change used to just add the new address and leave the old answers standing, so a region refusal collected behind a VPN could outlive the VPN. Rows are now marked stale on a generation bump — kept as history, but read as "not measured" — and a round still in flight when the path changes is superseded rather than joined. The slowest models in a round land last, which is exactly when they would otherwise overwrite fresh verdicts with obsolete ones. The throttle backoff is reset for the same reason: it described the old path's quota.
A refusal is about one model, not the roster. A mid-turn RegionError used to force a full probe round, spending one probe per model out of the same quota pool as your own turns to confirm something the refusal had already said about exactly one of them. It now records that single verdict directly, with no probe request and no invented latencyMs/startedAt.
What was deliberately not ported
The other ~44 commits in that range serve subsystems this fork does not carry: the magpie engine, token savers, the subscription lane, the native Gemini gateway, the workbench, combos, the self-updater, the inbound forward listener, the standalone UI. More to the point, none of the ten modules backing this plugin's actual primitives — stream, http, catalog, upstream, messages, recovery, effort, channel, route classification, failure — was touched upstream at all in that range. There is no hidden SSE, usage-accounting, fingerprint or window change hiding in there.
One upstream change was evaluated and declined on its merits: 6da81a3 ("disable all automatic probing") assumes a manual reprobe button and a settings UI that this Host-only plugin has no surface for. Ported literally it would leave every model reading unknown and make exposeRegionModels inert. The quota concern behind it is real, and the third fix above addresses the fork's actual instance of it.
Verification
- 13/13 modules pass
node --checkand import cleanly. - 15/15 behaviour assertions through a real cordis mount over a stubbed transport: three models produce three distinct derived sessions and never the old shared one;
egressGenerationstarts at 0; a region refusal fires zero probes; an egress flip bumps the generation and marks prior rows stale; a late old-generation round resolves to{}and leaves the store unchanged. - A latent mount-breaking bug was caught in review: returning an object from
applymakes cordis_executethrowTypeError: Invalid effectand fails the plugin entirely. The handles are now parked in aWeakMapand read viamountHandle(ctx). ConfirmedACTIVEagainst cordis 4.0.0-rc.10.
v2.2.0 — sync the free lane with upstream, and fix the per-model catalog
Ported from dsh-our-free-model f0b979f..482500f (v2.5.3 → v2.5.9), keeping only what belongs to the anonymous free lane. Upstream's other changes hang off subsystems this plugin does not ship (EAC, Kilo, ten account channels, the local OpenAI gateway, combos, the self-updater, the settings UI), so they were refused rather than copied.
Usage accounting
A free-lane multimodal turn can stamp a prompt larger than the model's own window (~619k total on a 256k model after a short vision turn), and the host soft-budgets from that sample, then fail-closes with CONTEXT_WINDOW_EXCEEDED while the real conversation is a few thousand tokens. The input side is now dropped before it is metered; output and reasoning counts survive, and once one attempt's prompt is rejected the turn holds input at 0 because no later sum rebuilds an exact prompt.
Upstream gates this on one known-bad router; the equivalent gate here is where the window came from — a real capability row, never the conservative fallback, because a stamp above a fallback proves the fallback is undersized rather than that the stamp is wrong.
Per-model catalog facts
Added the exact-id override layer and corrected the four ids this lane serves that the family patterns mis-stated:
| id | before | after |
|---|---|---|
deepseek-v4.1-flash-free |
128000 / 64000 / no vision | 1048576 / 384000 / vision |
glm-5.3-flash-free |
131072 / 32768 / no vision | 1048576 / 131072 / vision |
nemotron-3.5-super-vl-free |
128000 / 32768 / no vision | 1048576 / 131072 / vision |
big-pickle |
fallback | 262144 / 131072 / vision |
This was not cosmetic: the understated window made checkpointFits reject recovery continuations ~8× more eagerly on the model the README advertises first, maxOutput capped budgetFor 6× below what the gateway accepts, and three ids that accept images were advertised as text-only — one of them literally named -vl. Seeds now track the live roster (8 models).
Smaller ports
- Probe verdicts record when the probe started rather than when it answered, so a slow reply cannot read as fresher than a later round.
- A finish chunk carries
upstreamFinishwhen the fallback mapped an abnormal ending ontostop. - The Chat projection passes an assistant
reasoning_contentstring through verbatim (empty string included) while still never projecting internal reasoning blocks.
Deliberately not copied
The thinking menus stay this plugin's own 2026-10-18 measurement rather than upstream's narrower declared sets. Both are true — GLM answers 200 to none and spends reasoning tokens anyway — so a rung that is accepted but not honoured is a product decision, not a bug.
Verification
All 13 modules parse and import; 40 unit assertions over the clamp, the overrides, the effort invariants and the reasoning echo; and an end-to-end run through the real adapter against a local SSE fixture (clamp fires on a measured window, stays inert on the fallback, no-op on an honest stamp, and correctly lets a 619k stamp through on the corrected 1M window).
No breaking change for callers.
Install
dsh plugin --profile desktop add github:artorias-zj/dsh-opencode-free-modelv2.1.0 — thinking levels are the real control
Why this release exists
The effort menu was built on a probe that concluded this lane accepts reasoning_effort and then ignores it, leaving max_tokens as the only real control — so a level was implemented as an output-token budget.
A re-probe shows that conclusion no longer holds. The gateway now validates the field, and an undeclared value answers 400 naming the accepted set:
literal['none','minimal','low','medium','high','xhigh','max'] # deepseek-v4.1-flash-free
reasoning_effort: Invalid option: expected one of "max"|… # nemotron ids
And the level moves reasoning volume. With max_tokens pinned at 8192, three samples per rung, median reasoning tokens on deepseek-v4.1-flash-free:
| level | reasoning tokens |
|---|---|
| none | 0 |
| low | 793 |
| high | ~1000 |
| xhigh | ~1600 |
| max | ~2000 |
big-pickle: none 0 → max 1013. glm-5.3-flash-free: none 7 → max 6135.
The bug this fixed
The old light rung sent max_tokens: 2048. That ceiling no longer bounded the thinking — it truncated the turn before the answer started:
light (2048) finish=length reasoning 4807 chars answer 0 chars
Every level now returns a complete turn.
What changed
effort.jsrewritten. Ids are the upstream's own level names (none…max), sent verbatim asreasoning_effort. The token ceiling is independent of the level: a caller-suppliedmaxTokensstill lowers it, nothing raises it above the model.- The menu is per model, because the accepted sets genuinely differ:
big-picklerefusesminimal/xhigh;space-bunny-freerefusesnone(so it has no Off rung and reportscanDisableThinking: false). Sets were measured by sending every rung to every live free id — published tables are narrower than the wire (models.dev lists three rungs fordeepseek-v4.1-flash-freeandglm-5.3-flash-free; both accept all seven). - A level the model does not declare is never forwarded. It resolves to that model's default instead of drawing a 400.
defaultEffortis per model and always a rung in that model's menu — the harness rejects the whole roster otherwise.- The level rides continuations too. A recovery segment that dropped it would spend its ceiling on thinking again, which is the turn it is rescuing.
describe()names the menu instead of a token figure, which was a number the level does not honour.jev-1.13-freeleaves the seed roster: every wire answers400 ModelProtocolUnsupported, so the gateway lists a model it will not serve.
Breaking change
light / balanced / deep are gone in favour of the lane's own rungs. A saved stale preference is not an error — an unknown level resolves to the model's default — so it degrades quietly rather than failing a turn.
Verified
- 26 module-level checks: per-model menus, defaults always in menu, unsupported levels fall back, budget no longer varies with level, descriptions promise no token figures.
- 13 adapter checks through a stub gateway:
reasoning_effortreaches the wire,max_tokensis the full ceiling,space-bunny-free+none→low, default →medium,defaultEffortvalid for every model. - Live turns on all five rungs of
deepseek-v4.1-flash-free: every onefinish=stopwith a complete answer;big-pickle/space-bunny-free/glm-5.3-flash-freelikewise.
Full changelog: v2.0.0...v2.1.0
v2.0.0 — the free lane works again
Required upgrade
1.0.1 and every earlier release fail every turn with 403 FreeTierError. The gateway tightened two gates, and each one on its own is enough to refuse a request.
The session id no longer decoded
The gateway reads the first twelve hex digits of a ses_… as ~(time × 0x1000 + counter) and requires the counter to stay small: 0–63 answers, 64 and up is refused. The old derivation emitted a raw sha256 prefix, which decodes to counters in the hundreds (846, 203, 2479 for three test seeds), so every conversation was refused.
Derivation now builds the time field from the digest, pins the counter to 1, inverts it, and keeps the base62 tail from the digest — a canonical shape that is also stable across turns, which the per-session quota accounting needs.
The attribution User-Agent carried a refused token
The harness mandates an attribution User-Agent and states nothing can suppress it. This lane refuses exactly that product token — deepseek-harness/<version>, case-insensitively — while accepting the bare word, the same words inside a URL, and deepseek_harness/….
The plugin now sends opencode/1.18.31 dsh/<version> (+https://github.com/deepseek-ai/deepseek-harness): the client token the lane identifies, plus the attribution with only its product name respelled. lib/kernel.js still reports the harness's own string, so attribution stays honest at the seam; the deviation is documented in both READMEs.
Also in this release
FreeTierErroris reported asCLIENT_ERRORwithfingerprintRejected: true, notINVALID_CREDENTIAL. This lane has no per-user key, so credential guidance sent users looking for something that cannot exist.- The catalog matches the live roster: ten free ids, current display names,
big-pickleadmitted as free despite having no-freemarker, retired seeds removed. - Docs record that these wire facts are measured, not published — the gate can tighten again, and a
FreeTierErrormeans update, not re-authenticate.
Verified
Against the live lane: both gates fixed, 200 across four conversation seeds plus the catalog and probe sessions; a real streamed turn completes with a stop finish and correct token accounting (inputTokens 34, outputTokens 16, totalTokens 50, reasoningTokens 14, answer "OK").
Notes
Not gated, and deliberately not changed: /zen/v1 and /inference/v1 both answer, and x-opencode-client / x-opencode-request / x-opencode-session-id / x-opencode-project are each individually dispensable. Porting those cosmetics would have added surface with no measured effect.
Full changelog: v1.0.1...v2.0.0
v1.0.1 — upstream robustness fixes
Ports the free-lane robustness fixes from the upstream reference project:
Stream decoding
- omit an unknown tool-call name from deltas instead of emitting
undefined(the kernel lossless-JSON snapshot rejected the whole chunk) - downgrade a turn whose call never learned its name (issue #92: an answered nameless call poisoned every later turn)
- read streamed thinking under four spellings (
reasoning,reasoning_content,reasoning_text,thinking) plusreasoning_details - scrub leaked DSML control markup with a hold-back buffer
- accumulate capped answer text for max-tokens continuation
Recovery
- widen the total turn window to 30 minutes (issues #69/#70)
- continue once from the checkpoint when a stopped turn held only its reasoning (silent stop)
- continue automatically when finish
lengthcut the answer (issue #28)
Messages
- drop nameless calls in
repairToolPairingeven when answered - accept OpenAI-wrapped tool defs in
toToolDefs - buffer tool-result images so every result precedes user content
HTTP
- classify HTML error pages, signature rejections and 4xx failures by their real cause (issues #63/#50)
- append the socket-level cause chain to transport failures (issue #79)
v1.0.0 — initial release
The first release of dsh-opencode-free-model: use the OpenCode Zen free model lane from DeepSeek Harness with no API key.
- llm adapter registration with three wire protocols (chat / responses / messages)
- live model catalog with alphabetical picker order
- reasoning-effort ladder as an output-token budget
- reasoning-checkpoint continuation for cut streams
- fingerprint quartet with tool renaming
- MIT licensed