Repository navigation
v0.1.3 — plan usage, a live model cache, and a pi-ai-faithful conversion
Install:
dsh plugin --profile web add github:scavanger2221/dsh-llm-opencode-go#v0.1.3Then restart the app (dsh web) and set the key in Settings → Models → OpenCode Go.
Fixed
opencode-go has no model "deepseek-v4.1-flash"no longer kills a session: a refreshed model list is cached under$DSH_HOME, so a model the pinned pi-ai release predates resolves from the first request after a plugin remount.OpenCode Go cannot represent an image in an in-history tool messageis gone: a tool result is a first-classtoolmessage, and images inuserandtoolmessages replay instead of failing the turn.- Tool results were going to the wire as user text with their
toolCallIddropped; they now reach the wire astoolmessages.
Added
- A plan-usage panel in the settings card: 5-hour, weekly and monthly windows read from
GET /v1/usagethrough an authenticated host route, with a header pill, per-window progress bars, reset countdowns and distinct states for a missing key, an unauthorized key, a malformed reply and an unreachable endpoint.
Changed
- The harness ↔ pi-ai conversion mirrors
dsh-llm-pi-ai's own — same history refusals, sametoolResultprojection, same image handling, same offload and stop/usage/error mapping — and the mirror is a contract re-diffed on every harness or pi-ai upgrade (ADR 0004). - The settings card lives at the foot of the Models page (ADR 0002) and edits per-model overrides that state only what they change (ADR 0003).
Full changelog
-
Fix: an image in an in-history tool message failed the whole turn. A tool result is a first-class
toolmessage whose own content is the tool's output, soread_imagehistory was refused withOpenCode Go cannot represent an image in an in-history tool messageand every later turn in that session died on replay. Images inuserandtoolmessages now replay — the same two roles pi-ai's own conversion accepts — and the image reaches the wire from the tool result -
Fix: tool results were sent as something the user said. The conversion looked for
tool-resultblocks that harness 0.1.7 does not produce, so atoolmessage fell through to the user branch: its text was flattened into auserturn and itstoolCallIdwas dropped. A tool message is now projected as a pi-aitoolResult(toolCallId,toolName,isError), which is what the request carries -
The conversion is now a mirror of
dsh-llm-pi-ai, and the mirror is the contract. The harness↔pi-ai seam belongs to the shipped pi-ai adapter; this route reproduces it only because it must add the session headers, so both paths now refuse and accept exactly the same history:developermessages and tool-change blocks are refused ("Developer messages are not supported yet" / "Tool-change blocks require developer role"),deferLoadingtools are refused, images are legal inuserandtoolmessages and refused everywhere else, a leadingsystemmessage folds into the prompt the same way, user turns carry the sameuserContentcollapse, and offloaded images are skipped when the request images are collected.AGENTS.mdstates the rule — on a harness or pi-ai upgrade, re-diff the context region ofdsh-llm-pi-ai/lib/index.jsagainstlib/convert.js— andtests/adapter.smoke.mjspins the resulting refusals. The decision behind it, including what delegation tollm-pi-aiwould cost now that Go is known to accept pi-ai'sx-session-affinity, is ADR 0004 -
The Models list was rebuilt so it can be read. Every row repeated its field labels ("Context window: 131072", "Max output tokens: 32768"), which made 27 models look like debug output. A row now leads with the catalog's display name (the id beside it only when the two differ), states its facts in a compact spec line (
1M ctx · 128K out · text + images, exact values in the tooltip), and keeps its actions right-aligned: Disable/Enable, Edit, and Reset for a row that carries an override. The Advertised models section is gone — its count, its endpoint query and its refresh button moved into the Models field with a new filter box, and the count line now reports what is there and where it came from (27 models · 2 overridden · 1 disabled · live catalog). The list is a fixed six-row window, so it ends on a row boundary instead of clipping one, and a disabled row dims its text but not the button that brings it back -
The header chevron was never the real icon. The card asked for
IconChevronDownOutline14, which no shipped package exports, so it silently fell back to the text glyph▾. It now usesIconChevronDownOutlineRegular, the name every shipped package uses -
A model can now be disabled. A
models:entry takesdisabled: true— which is exactly what the card's per-model Disable button writes, and nothing else — and that takes the model out of the served catalog: the provider stops advertising it, the model picker stops offering it, and a request naming it fails with an explanation instead of being sent to a model the user switched off. The id stays described (catalog.all()), so the settings card keeps rendering its row with its facts (llm.registerModelDiscoveryanswers fromall(), while the picker readslistModels), and a live refresh never re-adds it, because every configured id is owned by configuration rather than by the endpoint. Re-enabling drops the entry again when the flag was all it carried, so switching a model off and back on leaves the configuration as it was -
The card now has per-model settings instead of a list of hand-written override entries that starts empty. It lists every model the route serves with the facts a request would use (context window, max output, input modalities, and the protocol when an override states one), marks the ones carrying an override, and edits any of them in place — no ID has to be known in advance. The host's
modelDiscoveryreply carries those facts (contextWindow,maxTokens,inputModalities), which is what the list renders -
An override may state only what it changes. A field left on Inherit is absent from the entry, so editing one model's context window no longer rewrites its protocol, reasoning flag, or allowed input: the catalog merge only takes what the entry carries.
LlmDiscoveredModelis the whole discovery contract and carries neither the protocol nor the reasoning flag, so before this an editor could only ever guess them — guessing wrong switched a model's wire protocol.tests/adapter.smoke.mjspins the partial-merge behavior, and the card test asserts an inherited field never reaches the write -
Model capacities are no longer compared by
JSON.stringify: a hand-edited profile patch that spells the same override with its keys in another order is not a change worth staging a rewrite for -
Fix image requests:
readImageRequesttakes a resolved{width, height, maxBytes}target, not the route's pixel/byte budget. The budget was passed through unresolved, so every request carrying an image failed withImage request width must be a positive integer. The target is now projected from the source's intrinsic size, so a small image is still never enlarged -
Track the
@deepseek-ai/dsh-llm0.1.7 image-offload seam:offloadRequestImagesWithPolicyis gone. Prepare the request images, then fail withIMAGE_OFFLOAD_REQUIREDcarrying therequiredImageOffloadcount and let the surface offload, projecting already-offloaded occurrences withprojectOffloadedImages -
Track the 0.1.7 settings seam: the
settingsScopeservice is gone. The card injectsconfigFormsand takes its namespace's controller withconfigForms.get(NS), which keeps the samegetSnapshot/subscribe/set/mutateface -
Fix: the plugin's configuration was silently ignored on 0.1.7. The Loader now hands
applya Schemastery Config whose fields are live references (config.apiRoot.get()), not a plain object; readingconfig.apiRootreturned a field object, so every value fell back to its schema default and a settings write looked like it did nothing. Configuration is now read through those references at the moment of use, and a plain object is still accepted.tests/host.smoke.mjsmounts a realConfigwith non-default values and asserts they reach the route, the directory, and the catalog -
Track the rest of the 0.1.7 settings seam:
settings.installSectionis gone. A mounted entry's Config is projected into a settings namespace automatically (keyed by the entry id), and a plugin that ships its own page registerssettings.configure({ auto: false })instead. The route's own unsupported-apicheck is gone with it — the Config schema already rejects that value -
Move the settings card to Settings → Models, in the
settings.models.footerseat (the old seat,settings.plugin.item, is declared by no shipped package on 0.1.7 and renders nowhere). The footer needs no provider-directory row, which matters becausesettings.models.provider-cardis unreachable for this route:llm-pi-aideclares the whole pi-ai provider catalog,opencode-goincluded, so that row dispatches with thellm-pi-ainamespace and the host refuses a second declaration of the same id. The route is still declared on the configurable-provider directory on a best-effort basis, and a refused declaration is logged at info as the expected coexistence -
Fix: the settings card rendered nothing on 0.1.7. The settings service serves a namespace only for a Config schema that declares at least one
.volatile()field —volatileForm(schema)returns nothing otherwise, soconfigForms.get(NS)reported the namespace unavailable and the card returnednull.apiRoot,refreshIntervalMs,models,refreshEpoch,apiKeyEnv, anddisplayNameare now volatile, which is exactly the set the card reads and writes;providerIdand the deployment-only fields stay ordinary. Because a volatile write commits into the running Config instead of remounting the entry, the plugin now rebuilds its catalog, refresher, and cached provider fromloader/volatile-updateand re-announces the route.tests/host.smoke.mjspins both the volatile metadata and the in-place update path