Lock OpenCode 2.x to your self-hosted model
Contents
Two config lines make OpenCode 2.x send prompts only to your self-hosted OpenAI-compatible model, and a self-contained script proves which model a headless run picks.
- Tested on
- OpenCode V2 (npm
@opencode/cli) 2.2.5, headlessopencode run, throwawayHOME, first in a container with no network, then with the PoC below behind a dead proxy (2026-11-02). The no-model case was also run on 2.2.0. - Not verified
- the interactive TUI, a fresh install's first-run screens, 2.2.1 to 2.2.4, a config with both lines at once,
"model"alone with the local server down, a full personal config. - Needs
- an
opencode2.x binary, a local OpenAI-compatible model server already configured as a provider (one way: /guides/serve-local-models-to-opencode-llama-router/),python3for the PoC. No opencode.ai account and no API key. - Time
- about 10 min, my estimate, not timed (the PoC caps each of its three runs at 60 s).
- Risk
- low. You edit one config file after copying it; the PoC points
HOMEand the fourXDG_*directories it sets (config, data, state, cache) at amktemp -ddirectory and starts a server on 127.0.0.1:18765 for the length of the run.
Before you start#
Placeholders used below:
<CONFIG>: your global OpenCode config file. The PoC writes its own as$XDG_CONFIG_HOME/opencode/opencode.jsoninside a throwawayHOME, so on a default setup this is~/.config/opencode/opencode.json.<PROVIDER_ID>: the key of your local provider under"provider"in<CONFIG>. The PoC useslocal.<MODEL_ID>: the key of your model under that provider's"models". The PoC usesqwen-local.<OPENCODE_BIN>: path to theopencode2.x binary; the PoC defaults toopencodeonPATH.
Check the version with
<OPENCODE_BIN> --version. Mine printedopencode v2.2.5(it is also the PoC's first output line).Back up the config before step 3:
cp <CONFIG> <CONFIG>.bak
Steps#
Save the PoC script as
opencode-default-model-poc.shand make it executable. It needs onlypython3and theopencodebinary. It starts a tiny fake OpenAI-compatible server on 127.0.0.1, then runsopencode run --standalone "say hi"three times in a throwawayHOME: A) the config names the local model, B) the same config without"model", C) B plus"disabled_providers": ["opencode"]. Every non-local request goes to a dead proxy on 127.0.0.1:9, so nothing can reach opencode.ai. The header comment says "twice"; the script runs three cases. That comment is out of date.#!/usr/bin/env bash # PoC: does OpenCode (V2, @opencode/cli 2.2.x) need an opencode.ai account to use a self-hosted model? # # Runs `opencode run` twice in a throwaway HOME against a tiny local OpenAI-compatible server # (started by this script, 127.0.0.1 only): # A) config names the local model ("model": "local/qwen-local") # B) same config without "model" # C) B plus the fix: "disabled_providers": ["opencode"] # All other traffic goes to a dead proxy (127.0.0.1:9), so nothing reaches opencode.ai. # Nothing outside the temp dir is touched; your own ~/.config/opencode is not read. # # Usage: ./opencode-default-model-poc.sh [path-to-opencode] (default: opencode on PATH) set -u BIN="${1:-opencode}" command -v "$BIN" >/dev/null || { echo "opencode binary not found: $BIN" >&2; exit 2; } command -v python3 >/dev/null || { echo "python3 needed for the fake model server" >&2; exit 2; } T=$(mktemp -d) PORT=18765 cat > "$T/fake.py" <<'EOF_PY' import json, sys, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class H(BaseHTTPRequestHandler): def log_message(self, *a): sys.stderr.write("fake-server: " + (a[0] % a[1:]) + "\n") def _json(self, obj): b = json.dumps(obj).encode() self.send_response(200); self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b) def do_GET(self): self._json({"object": "list", "data": [{"id": "qwen-local", "object": "model"}]}) def do_POST(self): body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") text = "LOCAL MODEL ANSWERED" if not body.get("stream"): return self._json({"id": "x", "object": "chat.completion", "created": int(time.time()), "model": "qwen-local", "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 3, "total_tokens": 4}}) self.send_response(200); self.send_header("Content-Type", "text/event-stream"); self.end_headers() def ev(d): self.wfile.write(b"data: " + json.dumps(d).encode() + b"\n\n"); self.wfile.flush() base = {"id": "x", "object": "chat.completion.chunk", "created": int(time.time()), "model": "qwen-local"} ev({**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": text}, "finish_reason": None}]}) ev({**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 3, "total_tokens": 4}}) self.wfile.write(b"data: [DONE]\n\n"); self.wfile.flush() ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() EOF_PY python3 "$T/fake.py" "$PORT" 2> "$T/fake.log" & FAKE=$! trap 'kill $FAKE 2>/dev/null' EXIT sleep 1 run_case() { # $1 label, $2 "with-model" | "no-model" | "fix" local h="$T/$1" model_line="" mkdir -p "$h/.config/opencode" "$h/proj" [ "$2" = with-model ] && model_line=', "model": "local/qwen-local"' [ "$2" = fix ] && model_line=', "disabled_providers": ["opencode"]' cat > "$h/.config/opencode/opencode.json" <<EOF_J { "\$schema": "https://opencode.ai/config.json", "provider": { "local": { "npm": "@ai-sdk/openai-compatible", "name": "local", "options": { "baseURL": "http://127.0.0.1:$PORT/v1" }, "models": { "qwen-local": { "name": "qwen-local" } } } }$model_line } EOF_J echo "=== case $1 ($2)" (cd "$h/proj" && env HOME="$h" XDG_CONFIG_HOME="$h/.config" XDG_DATA_HOME="$h/.local/share" \ XDG_STATE_HOME="$h/.local/state" XDG_CACHE_HOME="$h/.cache" PWD="$h/proj" \ HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 NO_PROXY=127.0.0.1,localhost \ OPENCODE_DISABLE_AUTOUPDATE=1 \ timeout 60 "$BIN" run --standalone "say hi" < /dev/null 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | sort -u | head -n 8) } echo "opencode: $("$BIN" --version 2>&1)" run_case A with-model run_case B no-model run_case C fix echo "=== requests the local server got (A and C should have some, B none):" grep -c 'POST' "$T/fake.log" || true echo "(temp dir: $T)"chmod +x opencode-default-model-poc.shRun it against your binary:
./opencode-default-model-poc.sh <OPENCODE_BIN>(or with no argument foropencodeonPATH). My run on 2.2.5 printed this (the invocation line was not captured):opencode: opencode v2.2.5 === case A (with-model) LOCAL MODEL ANSWERED === case B (no-model) > build · flint-40b-free Error: Transport: ECONNRESET: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() === case C (fix) LOCAL MODEL ANSWERED === requests the local server got (A and C should have some, B none): 4 (temp dir: /tmp/tmp.qySI9NmbUG)Expected: cases A and C print
LOCAL MODEL ANSWERED(the fake server's canned reply); case B prints> build · flint-40b-freeand an error; the last count is above zero. The count is one total ofPOSTlines in the fake server's log for the whole run; the script does not split it per case. The server's log held requests from A and C and none from B. Each case's lines pass throughsort -u | head -n 8, so they are sorted, not in the order OpenCode printed them; that is why case B starts with an empty line. If your case B also showsflint-40b-free, your binary behaves like 2.2.0 and 2.2.5: with no"model"in the config, it picks OpenCode's hosted free model.Name your local model in
<CONFIG>. Add this top-level key, next to"provider":"model": "<PROVIDER_ID>/<MODEL_ID>",In the PoC that is
"model": "local/qwen-local"(case A). Expected: the local model answers; in my offline run it also ran a shell tool (echo hello-from-tool) without a key or an account.Disable the hosted provider in
<CONFIG>. Add this top-level key:"disabled_providers": ["opencode"],This alone was case C: no
"model"key, and the local model answered. On 2.2.5 with the local server up, either step alone was enough; step 4 is the one that removes the hosted models from the choice. Doing both steps leaves both lines set, a combination I have not run (see Scope). For reference, this is the whole config the PoC writes for case C (its heredoc withPORT=18765):{ "$schema": "https://opencode.ai/config.json", "provider": { "local": { "npm": "@ai-sdk/openai-compatible", "name": "local", "options": { "baseURL": "http://127.0.0.1:18765/v1" }, "models": { "qwen-local": { "name": "qwen-local" } } } }, "disabled_providers": ["opencode"] }
Verify#
- The PoC output in step 2 is the proof for the mechanism: with
"model"(A) or"disabled_providers"(C) the request went to the local server; without either (B) OpenCode choseflint-40b-freeand tried opencode.ai. - For your own config, run a headless
opencode runwith it and check that your model server logged the request and that the output does not nameflint-40b-free.[UNVERIFIED: not run against a full personal config; only the PoC's bare config was tested]
Undo / rollback#
- Restore the config:
cp <CONFIG>.bak <CONFIG>.[UNVERIFIED: rollback not exercised] - The PoC stops its fake server on exit (
trap ... EXIT) and leaves its temp directory behind; the last output line prints its path. Delete it by hand if you want it gone.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
Session line > build · flint-40b-free, then Error: getaddrinfo EAI_AGAIN opencode.ai (no network) | No "model" in the config: OpenCode picked its hosted free model and tried to reach opencode.ai | Step 3 or step 4 |
Session line > build · flint-40b-free, then Error: Transport: ECONNRESET: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() (behind a dead proxy) | Same: hosted free model chosen, request to opencode.ai refused by the proxy | Step 3 or step 4 |
Field notes#
- 2026-11-02, throwaway
HOME, OpenCode V2 (@opencode/cli) 2.2.5 and 2.2.0, scripted fake model on 127.0.0.1:- I checked a forwarded claim that fresh OpenCode 2.x will not use a self-hosted model until you activate an opencode.ai account and buy a subscription, and that it asks for an API key on the first prompt. The newest version at test time was 2.2.5 (published 2026-10-30); 2.2.0 was published 2026-10-27.
- Bare config (no hardened template), only a local provider through
@ai-sdk/openai-compatiblewith itsbaseURLon 127.0.0.1, no account, no API key, headlessopencode run, first in a container with no network at all. - With
"model": "local/qwen-local"the local model answered and ran a shell tool (echo hello-from-tool). Without"model"the session line read> build · flint-40b-freeand the run ended withError: getaddrinfo EAI_AGAIN opencode.ai. 2.2.0 pickedflint-40b-freethe same way, so this is not new in 2.2.5. With"disabled_providers": ["opencode"]and no"model", the local model answered. - Then the PoC above with the network on and all non-local traffic sent to a dead proxy: output as in step 2. The no-model case now ended in
ECONNRESETinstead ofEAI_AGAIN. - The account and API key part of the claim did not hold; the "defaults to the hosted model" part did, when the config names no model. I could not reproduce the API key prompt: the dead proxy cut the no-model case off before opencode.ai answered. In an earlier test on 2.2.0 the hosted model answered with no key at all (/posts/installed-apart-still-read-home/), so where the prompt in the claim came from is still open.
- The string
disabled_providersis present in the 2.2.0 binary, and the key works in 2.2.5 (case C). - My hardened V2 template (which denies the
opencodeprovider by policy, see Notes) passed the same guardrail cases on 2.2.5 as on 2.2.0: 3 of 3 cases in a quick run.
Notes#
- Hardened templates (public, MIT, gitlab.com/myrants-oss/harnesses): the V1 template sets
"disabled_providers": ["opencode"](opencode/v1/opencode.jsoncline 22). The V2 template adds a global policy{ "action": "provider.use", "resource": "opencode", "effect": "deny" }(opencode/v2/opencode.jsoncline 842); global policies outrank anything a project config says. Background on those templates: /posts/guardrails-first-real-evening/. - Earlier, while building those templates on OpenCode V1 1.19.5 and V2 2.2.0: with no local model answering, OpenCode fell back to the hosted free models, so the prompt and code left the machine, and V2 2.2.0 without a deny chose the hosted model even with a local one up. The templates rely on the deny (step 4); I have not tested
"model"alone with the local server down. opencode-disable-zenis a third-party npm package that exists (the registry answered 200). Nothing here needed it; I did not install or review it.- The story of the claim and how I checked it: /posts/opencode-account-myth/.
Sources#
- gitlab.com/myrants-oss/harnesses: the public templates; V1 and V2 OpenCode configs with the lines cited in Notes.