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/messagesclient is officially LiteLLM-compatible viaANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN. A spawn resolving to aModelRowsrow with a non-emptyGateway(today, only"litellm") gets those two env vars injected on top of the normalclaude -pspawn (resolveGatewayEnvininternal/hub/agent_runner.go) -- same harness, swapped model endpoint. backend=opencode(ARGUS-1570): a third, independent backend that execs theopencodeCLI instead ofclaude -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, sointernal/hub/opencode_backend.gowrites 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:
ARGUS_LITELLM_URLin the runner's own env (~/.argus/agent-runner.env, alongsideARGUS_HUB_URL/ARGUS_CAPTURE_TOKEN) -- the LiteLLM proxy root,http://192.168.1.28:4000(no trailing/v1;claude -p'sANTHROPIC_BASE_URLis set to exactly this value byresolveGatewayEnv). This is the runner's OWN env, not a secret --cmd/agent-runner/main.goreads it directly and threads it intoDefaultResearchRunner. Without it, a gateway-model spawn fails loud (gateway %q model requires ARGUS_LITELLM_URL configured but none is set) rather than silently hittingapi.anthropic.comunder an alias LiteLLM was supposed to serve.ARGUS_LITELLM_KEYresolvable via secret-material -- theANTHROPIC_AUTH_TOKENa gatewayclaudespawn sends, and the LiteLLM API key an opencode config'sapiKeyresolves. It is on the fixedsecretMaterialAllowlist(internal/hub/secret_material.go) thatPOST /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 theclaude-backend gateway path this resolves in-process (resolveGatewayEnv->resolveAuth->litellmAuthKey,internal/hub/agent_runner.go); on theopencode-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.- The
opencodeCLI, onPATHor at/usr/local/bin/opencode--resolveOpencodeBinary(internal/hub/opencode_backend.go) triesexec.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 withopencode binary not found in PATH nor at /usr/local/bin/opencode. ~/.config/opencode/opencode.jsoncwith the LiteLLM provider and thelocal-30balias (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'swriteOpencodeConfigFileonly 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 thepve-builderrunner (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:
- Force the backend for a project's next dispatch --
POST /projects/{slug}/dispatch(bearer,handleDispatchNowininternal/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.
- 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 ownmodelfield tolocal-30bwins at the TOP of that ladder regardless of project/global config, on any backend exceptcodex(which never resolves a Claude/LiteLLM model -- it has its own quota and model). This is the samemodelfield the hub'snext-taskresponse 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 explicitfile_scopeand thehub-build-locallane 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 jsonNDJSON event schema is not publicly documented (checked as of ARGUS-1570);parseOpencodeResultis deliberately defensive and falls back to raw stdout when a line doesn't match a recognized shape. A livebackend=opencoderun 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
CostUSDis a true0, not an unmeasured one (opencodeUsageToAgentRunUsage) -- do not read a0cost 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 toclaudeafter 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.