Manual

Lock OpenCode 2.x to your self-hosted model

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, headless opencode run, throwaway HOME, 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 opencode 2.x binary, a local OpenAI-compatible model server already configured as a provider (one way: /guides/serve-local-models-to-opencode-llama-router/), python3 for 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 HOME and the four XDG_* directories it sets (config, data, state, cache) at a mktemp -d directory 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.json inside a throwaway HOME, 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 uses local.
    • <MODEL_ID>: the key of your model under that provider's "models". The PoC uses qwen-local.
    • <OPENCODE_BIN>: path to the opencode 2.x binary; the PoC defaults to opencode on PATH.
  • Check the version with <OPENCODE_BIN> --version. Mine printed opencode v2.2.5 (it is also the PoC's first output line).

  • Back up the config before step 3:

    cp <CONFIG> <CONFIG>.bak
    

Steps#

  1. Save the PoC script as opencode-default-model-poc.sh and make it executable. It needs only python3 and the opencode binary. It starts a tiny fake OpenAI-compatible server on 127.0.0.1, then runs opencode run --standalone "say hi" three times in a throwaway HOME: 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.sh
    
  2. Run it against your binary: ./opencode-default-model-poc.sh <OPENCODE_BIN> (or with no argument for opencode on PATH). 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-free and an error; the last count is above zero. The count is one total of POST lines 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 through sort -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 shows flint-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.

  3. 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.

  4. 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 with PORT=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 chose flint-40b-free and tried opencode.ai.
  • For your own config, run a headless opencode run with it and check that your model server logged the request and that the output does not name flint-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)CauseFix
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.aiStep 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 proxyStep 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-compatible with its baseURL on 127.0.0.1, no account, no API key, headless opencode 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-free and the run ended with Error: getaddrinfo EAI_AGAIN opencode.ai. 2.2.0 picked flint-40b-free the 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 ECONNRESET instead of EAI_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_providers is present in the 2.2.0 binary, and the key works in 2.2.5 (case C).
    • My hardened V2 template (which denies the opencode provider 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.jsonc line 22). The V2 template adds a global policy { "action": "provider.use", "resource": "opencode", "effect": "deny" } (opencode/v2/opencode.jsonc line 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-zen is 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/.

Filed under: opencode, local-llm, ai-agents, security

Sources#