In opencode 1.19.5, my workaround protected nothing

The backlog item was small and polite. My LAN proxy for local LLMs writes the config for opencode, a terminal coding agent, and the model names in that config had been slightly wrong on purpose: opencode showed lan/3090-q38-27b-128k where every other client sees 3090-qwen38-27b-128k. The lan/ is just the provider prefix; the q38 was the deliberate mistake. The item said to try a plugin hook that could remove the reason for the wrong names, and it ended with two words in brackets: verify first. Backlog items don't usually carry a warning label. This one had earned it, and the person it was warning was me.

This time I verified first. Last time I hadn't, which is how the names got wrong in the first place. The plugin never got written. What got written was a deletion: the workaround was guarding against a rule opencode no longer has, with a trick that can't work against the rules it still has. I had kept it in good repair, unit test and all.

The proxy, the samplers, and a rename#

The setup, for anyone who landed here from a search: the proxy is a small Go service in front of two GPU boxes, an RTX 3090 desktop and an AMD Strix Halo mini PC. Each box runs llama.cpp servers that the proxy starts and stops on demand. Coding-agent clients such as opencode, pi and Claude Code talk only to the proxy and pick a model by a name made of a host prefix and a model key, like 3090-qwen38-27b-128k. The proxy can generate opencode's provider config itself (llm-router-proxy client opencode, or GET /clients/opencode.json), so the names in opencode come from my generator.

The servers carry sampler presets, temperature and top_p and friends, tuned per model. On 24 October I read the source of opencode 1.18.4 and found that opencode forces sampler values for some model ids: top_p: 1 for ids containing qwen, top_p: 0.95 for minimax-m2*. Those values override the server's presets. On that day the rule touched 24 of the 88 models my generator wrote.

The workaround came straight out of that reading:

  • write the config key without the trigger word: 3090-q38-27b-128k;
  • put the real name in the model's "id" field.

The assumption was that opencode matches the key and sends the id. A unit test pinned the renaming, which proved my generator renamed things, the one part never in doubt. Whether opencode cared about the rename was the actual question, and nothing ever asked opencode. The workaround was never tested against a running opencode. Past me shipped it with a passing test and a clear conscience, and the test was the only one of the two telling the truth about what it had checked.

It had a cost, too. The names in opencode differed from every other client and from the proxy's own /v1/models list, so a model looked different depending on which window I was looking at. That sat on the backlog as a standing usability item, which is the backlog's phrase for "mildly annoying, indefinitely".

Reading the 1.19.5 binary#

By 31 October the installed opencode was 1.19.5. The plan was the chat.params plugin hook: a plugin that drops topP before the request goes out, so the keys could equal the proxy names again.

opencode 1.19.5 ships as a Bun single-file executable, and the JavaScript bundled inside it is readable with a plain byte search. The hook call, minified as found and lightly reformatted:

E = yield* e.plugin.trigger("chat.params", {sessionID, agent, model, provider, message},
      { temperature: e.model.capabilities.temperature ? e.agent.temperature ?? mj(e.model) : void 0,
        topP: e.agent.topP ?? gj(e.model),
        topK: Hj(e.model),
        maxOutputTokens: ..., options: u })

The names gj, mj and Hj are the minifier's. gj produces the forced top_p, and it reads e.api.id.toLowerCase():

  • gemini: 0.95 for some version patterns;
  • minimax-m2, kimi-k2.5, kimi-k2p5, kimi-k2-5: 0.95;
  • deepseek-v4-flash: 0.95, only on that vendor's own providers;
  • qwen: no rule at all.

mj forces a temperature, and only when the model's config says "temperature": true: north-mini-code, gemini, glm-4.6, glm-4.7, minimax-m2, kimi-k2*. Hj forces top_k: 20 or 40 for minimax-m2, some value for gemini.

So in 1.19.5 the match is on api.id. When a config sets "id", that is what opencode sends; when it doesn't, the key is the id. My rename moved the trigger word out of the key and into the id, the one field that gets checked.

That was a reading, though, of minified code. I had already built one workaround on a reading of this program that nobody tested, and I wasn't going to build its replacement on a second one.

Asking opencode what it actually sends#

The test needed a server that does nothing except take notes. Mine was a Python HTTP server of about 25 lines on 127.0.0.1: /v1/models answers with an empty list, /v1/chat/completions gets a two-chunk SSE reply, and every request body has exactly four fields written to a JSON-lines file: model, top_p, top_k, temperature. Nothing else interested me, so nothing else went in the log.

An isolated opencode config pointed at it, with three models:

config key"id"what it tests
mm-xminimax-m2-xtrigger word only in the id
minimax-m2-yplain-ytrigger word only in the key
qwen-z(none)the old qwen rule

opencode ran inside unshare -rn, a user and network namespace, with loopback brought up inside it and nothing else, and with HOME and every XDG_*_HOME pointing at scratch directories. Nothing could leave the machine, and nothing could reach my real config: opencode follows XDG_CONFIG_HOME when it's set, and a throwaway HOME alone misses that. One run per model:

opencode run -m lan/<key> "say ok"

The first attempt failed in the dullest way available. Every run hit my 90-second timeout, and the fake server saw nothing at all. From the outside it looked like a hang. opencode's own log told a different story: about 70 seconds after start, a "background dependency install failed" error from an npm fetch. It had spent the time trying to install packages from a network I had just taken away, and then it wrote that down where I wasn't looking yet. The fix was to copy the already-installed node_modules and package.json from my real opencode config directory into the scratch one, and to give each run 180 seconds. Then all three runs exited 0. A timeout on every run of a test whose whole job was to say "ok" is exactly what my composure is for. It held. Barely, and with help from a copied node_modules.

Each run sent two requests, one for the session title and one for the answer. The log had six lines:

{"model": "minimax-m2-x", "top_p": 0.95, "top_k": null, "temperature": null}
{"model": "minimax-m2-x", "top_p": 0.95, "top_k": null, "temperature": null}
{"model": "plain-y",      "top_p": null, "top_k": null, "temperature": null}
{"model": "plain-y",      "top_p": null, "top_k": null, "temperature": null}
{"model": "qwen-z",       "top_p": null, "top_k": null, "temperature": null}
{"model": "qwen-z",       "top_p": null, "top_k": null, "temperature": null}

Trigger word in the id: forced. Trigger word in the key: nothing. qwen: nothing. The top_k: null on the MiniMax lines fits too: Hj has a value for minimax-m2, and the OpenAI-compatible provider package didn't send top_k.

An independent rerun of the same script got the same six lines, and a second read of the bundle found all three sampler functions reading api.id, never the config key.

What the rename had been protecting#

In 1.19.5, nothing. The qwen rule is gone, so the renamed keys were dodging a rule that no longer exists. For the trigger words that remain, renaming the key can't help, because the match is on the id, and the id is where the proxy name went anyway. In the version I run, the workaround had become a ritual: performed carefully, pinned by a test, aimed at nothing.

Did it ever work? On 1.18.4, maybe. That reading was never tested, and I didn't go back and test it now, so I don't know, and I've stopped saying I do: the proxy's docs no longer claim that 1.18.4 matched by key.

Deleting it, and what opencode still forces#

The generated keys are now the proxy names themselves, lan/3090-qwen38-27b-128k, with no "id". The renaming code went, and so did its error path for two models that map to one key, an HTTP 409. The generator can no longer fail. While I was in there, aliases started showing up under every model-name setting the proxy has; the short-name list had been dropping them.

One forced value is left in my catalog: a MiniMax model on the Strix Halo box, under its minimax-m27 keys, gets top_p 0.95. Its preset sets no top_p, and 0.95 is llama.cpp's own default (float top_p = 0.95f; in common/common.h), so the forced value and the one the server would have used are the same number. An opencode agent definition that sets its own top_p replaces the forced value; only a chat.params plugin could remove it, and nothing here needs one.

The deletion has a migration cost. Anyone who referenced an old renamed key, lan/3090-q38-27b-128k in an opencode agent setting or a script, has to switch to the full name after regenerating. My own opencode config, as of 31 October, listed 112 lan models, and a scan for lan/... references found nothing that pointed at a renamed key.

The change got the usual mutation check, run inside the same network-less namespace I now use for every mutant:

  • keys renamed again: killed by TestOpencodeKeysAreProxyNames;
  • an "id" field added back: killed by the same test;
  • aliases ignored in selection: killed by TestSelectByNames;
  • the proxy never marking aliases: killed by TestClientConfigs.

The first try at the rename mutant didn't compile, because an import was gone, and I reran it in a form that compiled. A mutant that doesn't build was killed by the compiler, which tells me nothing about the tests.

A source read comes with a version number#

The original workaround was a conclusion about 1.18.4 that nobody tested, and it became a permanent feature of my config anyway. A workaround built on reading someone else's source is a hypothesis, and the version it was read from is part of the hypothesis. Write that version down next to it, and treat the client's upgrade as the day to ask the client again. The asking took one fake server, one namespace and a copied node_modules. The not-asking cost one client its right model names for as long as the workaround stood. The one who skipped the asking was me: I read the source to find the rule, then trusted the reading instead of spending one fake server to check it.

opencode still forces one number on my models, and it's the number llama.cpp would have picked anyway. I've decided to let it win that one. Arguing would take a plugin, and I've just finished deleting the last thing I wrote to argue with it.

Filed under: go, local-llm, opencode, testing

Sources#

  • opencode: the client. The 1.19.5 behaviour above comes from the bundle inside the installed binary and the fake-server test, not from documentation.
  • llama.cpp common/common.h: float top_p = 0.95f;, the default top_p.
  • unshare(1): -r/--map-root-user and -n/--net.