Skip to content

Gateway models and the opencode backend: runner host prerequisites (ARGUS-1597)

ARGUS-1566 (gateway spawn), ARGUS-1570 (opencode backend) and ARGUS-1571 (lane policy) landed a third and fourth agent backend -- a LiteLLM-gateway backend=claude spawn and a standalone backend=opencode -- but their mechanics stayed spread across three PR bodies and the orchestrator's notes. Nothing in docs/reference told an operator what a runner host actually needs wired up to serve either kind of run, which LiteLLM model aliases exist, or how to force one. This document is that inventory, written against the live, already-configured pve-builder runner host (2026-08-23).

Why this exists: two backends, one LiteLLM proxy

Argus originally had two agent backends, both vendor subscriptions: backend=claude (claude -p) and backend=codex. Neither could target a local or API-billed model. The homelab already runs everything else behind LiteLLM (pve-llm-infra, 192.168.1.28:4000: local/reasoning tiers on the 5070 Ti, fast=Groq, smart=Claude, embed), so ARGUS-1565 added a generic path instead of a third subscription:

  • Gateway model on backend=claude (ARGUS-1566): claude -p's own /v1/messages client is officially LiteLLM-compatible via ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. A spawn resolving to a ModelRows row with a non-empty Gateway (today, only "litellm") gets those two env vars injected on top of the normal claude -p spawn (resolveGatewayEnv in internal/hub/agent_runner.go) -- same harness, swapped model endpoint.
  • backend=opencode (ARGUS-1570): a third, independent backend that execs the opencode CLI instead of claude -p. opencode is model-agnostic and configures the LiteLLM endpoint through its own per-host config file rather than two env vars, and (unlike codex) it supports both MCP servers and a rendered agent file, so internal/hub/opencode_backend.go writes both into the spawn's working directory before exec.

The LiteLLM model aliases

ModelRows (internal/hub/agent_runs.go) is the allowlist every model picker (task override, project config, the UI) shares. The one gateway row today:

ModelRows.ID Gateway Backing model When to use
local-30b litellm Qwen3.8-27B q3 (per homelab-apps PR 51) local model via LiteLLM; no subscription spend

local-30b is a LiteLLM alias, not a hardcoded model string: LiteLLM's own litellm-config.yaml maps it to the actual served model (Qwen3.8-27B Q3_K_XL, running on the 5070 Ti). Adding a new gateway model in the future is a single ModelRows row -- spawn env, the pre-claim budget-gate exemption, milestones, evidence, and landing all key off Gateway/gatewayForModel(model) with no further code.

On a runner host's own ~/.config/opencode/opencode.jsonc (below), the same alias is declared a second time with its context limits -- opencode has no way to introspect these from LiteLLM itself, so they are hand-declared per model:

"local-30b": {
  "name": "local-30b (Argus gateway alias -> Qwen3.8-27B q3)",
  "limit": { "context": 49152, "output": 8192 }
}

What a runner host needs

A runner host (the machine cmd/agent-runner polls from -- see "Runner host list" below) needs the following before it can serve either a gateway-model claude spawn or a backend=opencode spawn:

  1. ARGUS_LITELLM_URL in the runner's own env (~/.argus/agent-runner.env, alongside ARGUS_HUB_URL / ARGUS_CAPTURE_TOKEN) -- the LiteLLM proxy root, http://192.168.1.28:4000 (no trailing /v1; claude -p's ANTHROPIC_BASE_URL is set to exactly this value by resolveGatewayEnv). This is the runner's OWN env, not a secret -- cmd/agent-runner/main.go reads it directly and threads it into DefaultResearchRunner. Without it, a gateway-model spawn fails loud (gateway %q model requires ARGUS_LITELLM_URL configured but none is set) rather than silently hitting api.anthropic.com under an alias LiteLLM was supposed to serve.
  2. ARGUS_LITELLM_KEY resolvable via secret-material -- the ANTHROPIC_AUTH_TOKEN a gateway claude spawn sends, and the LiteLLM API key an opencode config's apiKey resolves. It is on the fixed secretMaterialAllowlist (internal/hub/secret_material.go) that POST /internal/secret-material {"keys":["ARGUS_LITELLM_KEY"]} (bearer, capture-token group) will ever resolve -- the same route and allowlist the memory service's embed/distill tiers already use, never a second credential path. On the claude-backend gateway path this resolves in-process (resolveGatewayEnv -> resolveAuth -> litellmAuthKey, internal/hub/agent_runner.go); on the opencode-backend path the key has to land on disk as a file opencode can read (see the config example below) -- there is no equivalent in-process env injection for the opencode CLI's own config resolution.
  3. The opencode CLI, on PATH or at /usr/local/bin/opencode -- resolveOpencodeBinary (internal/hub/opencode_backend.go) tries exec.LookPath("opencode") first, then falls back to the canonical install location /usr/local/bin/opencode. Either satisfies it; a host with neither fails the spawn with opencode binary not found in PATH nor at /usr/local/bin/opencode.
  4. ~/.config/opencode/opencode.jsonc with the LiteLLM provider and the local-30b alias (with declared context limits) -- opencode resolves its own per-project/global config independently of anything the Argus spawn writes into the worktree (internal/hub/opencode_backend.go's writeOpencodeConfigFile only ever writes the MCP server block into the spawn's --dir, never a provider/model block). The provider config has to already exist on the host. Confirmed-live shape on the pve-builder runner (secret redacted):
{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "litellm": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "LiteLLM (homelab)",
      "options": {
        "baseURL": "http://192.168.1.28:4000/v1",
        "apiKey": "{file:/home/builder/.config/opencode/litellm.key}"
      },
      "models": {
        "local-30b": {
          "name": "local-30b (Argus gateway alias -> Qwen3.8-27B q3)",
          "limit": { "context": 49152, "output": 8192 }
        },
        "qwen3.8-27b-q3": {
          "name": "Qwen3.8-27B Q3_K_XL (5070 Ti)",
          "limit": { "context": 49152, "output": 8192 }
        },
        "local":  { "name": "local tier (qwen3-14b)", "limit": { "context": 32768, "output": 8192 } },
        "fast":   { "name": "fast tier (Groq)", "limit": { "context": 131072, "output": 32768 } },
        "smart":  { "name": "smart tier (Claude Sonnet)", "limit": { "context": 200000, "output": 64000 } }
      }
    }
  }
}

Two things worth noting about this shape: the baseURL carries the /v1 suffix opencode's @ai-sdk/openai-compatible provider expects (the runner-env ARGUS_LITELLM_URL above does NOT carry it -- same host:port, different path per consumer), and apiKey resolves through opencode's own {file:...} config syntax against a chmod-600 file (~/.config/opencode/litellm.key) rather than an inlined value or an env var -- opencode has no equivalent of resolveAuth, so the ARGUS_LITELLM_KEY value has to already be materialized on disk before a backend=opencode spawn runs.

opencode resolves a model ONLY as <providerID>/<modelID> -- never a bare model id (ARGUS-1599). The first live opencode run (run 3149, 2026-08-23, forced via POST /projects/argus/dispatch {"backend":"opencode"} on model=local-30b) reproduced this directly: --model local-30b -> opencode immediately emitted {"type":"error", "error":{"name":"UnknownError","data":{"message":"Unexpected server error. Check server logs for details.","ref":"err_f13e213a"}}}; --model litellm/local-30b -> the model answered normally. provider in the config block above is the <providerID> half (litellm in this shape), so buildOpencodeArgs (opencode_backend.go) qualifies every ModelRows id into <providerID>/<modelID> before passing it to --model (qualifyOpencodeModel) -- local-30b becomes litellm/local-30b, and the opencodeDefaultModel fallback ("local", used when a spawn carries no model override) becomes litellm/local. The provider id itself defaults to "litellm" but is overridable per runner host via ARGUS_OPENCODE_PROVIDER, so a host whose opencode.jsonc names the block something else is a config change, not a code change. A model id that already carries a / is assumed pre-qualified and passed through unchanged.

Separately, an opencode output stream whose only (or terminal) event is {"type":"error",...} -- the object-shaped error above, not the bare string parseOpencodeResult originally assumed -- now fails the run with that message (name + data.message + data.ref) recorded on the run's error field, instead of being silently read as a report-less success (opencodeErrorMessage, opencode_backend.go). Before this fix, the bare-string-typed Error field made json.Unmarshal fail on an object-shaped error line entirely, so the line was skipped as unparseable and -- being the stream's only line -- the parser fell through to its raw-stdout fallback and returned success; run 3149 was recorded status=done with an empty report, no milestones, and 0 tokens, so the failure was invisible to the lane-breaker and the task (the next tick simply re-claimed it on backend=claude).

No install script writes this file today (scripts/install-pve-builder- runner.sh does not touch opencode or LiteLLM config at all) -- it was hand-configured on pve-builder. A future runner host repeating this setup should follow this document rather than re-deriving it.

Runner host list

ARGUS_RUNNER_NAME (cmd/agent-runner/main.go, ARGUS-730) identifies a runner to the hub's runners.{name}.* tunables (Settings -> Runners). Three runner hosts exist today:

ARGUS_RUNNER_NAME Host Role
host (unset default) .96 Primary runner; claude + its OAuth live here; agent-runner daemon out of the hub container by necessity.
mac Aaron's Mac Satellite lane (launchd-managed, scripts/install-mac-runner.sh); mac-build-class skills.
builder pve-builder, LXC CT113 on dumbledore Satellite lane (scripts/install-pve-builder-runner.sh); this is the hub-build board runner and the confirmed-live host for the gateway/opencode prerequisites documented above.

Only pve-builder (builder) is confirmed, as of 2026-08-23, to carry the ARGUS_LITELLM_URL env, a resolvable ARGUS_LITELLM_KEY, the opencode CLI, and ~/.config/opencode/opencode.jsonc -- the .96 (host) and Mac (mac) runners are not verified to have any of this wired up. A gateway-model or backend=opencode dispatch should target pve-builder (or whichever runner host this document is next confirmed against) until the other hosts are provisioned the same way.

Forcing one run

Two ways to force a build against a gateway model or the opencode backend instead of the normal dispatch ladder:

  1. Force the backend for a project's next dispatch -- POST /projects/{slug}/dispatch (bearer, handleDispatchNow in internal/hub/dispatch.go):
curl -sS -X POST "$ARGUS_HUB_URL/projects/argus/dispatch" \
  -H "Authorization: Bearer $ARGUS_CAPTURE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"backend":"opencode"}'

An empty/omitted "backend" uses the dispatcher's normal route: usually "claude", except for a task classified into a lane policy such as hub-build-local (see Local 27B dispatch lanes). A backend of "codex" is also accepted, unchanged. An explicit backend overrides only the backend for that one enqueue; the model still resolves through the normal ladder below unless a task-level override or lane policy also applies.

  1. Force the model for one task -- resolveDispatchModel (internal/hub/dispatch.go) is a ladder: task override > per-skill override > project main > project fallback (7d-threshold failover) > global > env, first allowed value wins. Setting a task's own model field to local-30b wins at the TOP of that ladder regardless of project/global config, on any backend except codex (which never resolves a Claude/LiteLLM model -- it has its own quota and model). This is the same model field the hub's next-task response already surfaces per task (e.g. {"id":"ARGUS-1597", ..., "model":"local-30b"}). It is independent of the ARGUS-1616 docs/lint classifier, which now uses explicit file_scope and the hub-build-local lane policy rather than a model override.

Combining both -- forcing backend=opencode AND a task carrying model=local-30b -- routes that dispatch through the opencode CLI against the local 30B-class model end to end.

Caveats

  • opencode's own opencode run --format json NDJSON event schema is not publicly documented (checked as of ARGUS-1570); parseOpencodeResult is deliberately defensive and falls back to raw stdout when a line doesn't match a recognized shape. A live backend=opencode run is what confirms or corrects this parser (ARGUS-1571's live check), not this document.
  • An opencode/gateway run against a local model has no vendor subscription to weigh against, so its recorded CostUSD is a true 0, not an unmeasured one (opencodeUsageToAgentRunUsage) -- do not read a 0 cost run as a broken cost pipeline.
  • A lane's executor policy (internal/hub/lane_policy.go, ARGUS-1571) can independently pin a lane (e.g. a docs-only task class) to a local/opencode backend with automatic fallback to claude after a configured run of consecutive local failures -- that classification is the caller's decision (dispatch, a schedule, or a task filer), not something this document's forced-dispatch recipes control.