| description | Run OpenCode, the open-source terminal coding agent, as the agent under evaluation in Coder Eval — installation, provider authentication, model selection, and how its event stream maps to sandboxed, weighted scoring. |
|---|
OpenCode is an open-source terminal coding agent. Coder Eval drives it non-interactively:
opencode run --format json -m <provider/model> --dir <sandbox> --auto --pure "<prompt>"--format json streams newline-delimited JSON events on stdout, one event per
line. OpenCodeAgent reduces that stream into the standardized event protocol
(AgentStart / TurnStart / ToolStart / ToolEnd / TurnEnd / AgentEnd) and
lets EventCollector build the TurnRecord, exactly like every other harness.
Because OpenCode is model-agnostic, this is the cheapest way to evaluate a broad set of open-weight models (DeepSeek, Kimi, GLM, …) through a single agent.
OpenCode is a Node CLI, not a Python package:
npm install -g opencode-ai
opencode --versionThe coder-eval[opencode] extra exists for symmetry with the other harnesses and
carries no Python dependencies — the agent shells out to the binary above and
imports no third-party package:
uv sync --extra opencode # documents the opt-in; installs no extra packagesThe
opencode-aipackage on PyPI is an HTTP client foropencode serve, a different integration surface than the CLI this harness drives. You do not need it.
If the binary is missing, the task fails at start() with an actionable error
naming the install command, rather than failing obscurely mid-run.
OpenCode resolves credentials itself, per provider. Either log in once:
opencode auth login…or export the provider key, which Coder Eval forwards into the subprocess
environment (it also picks up keys from .env, since config.py calls
load_dotenv(override=True)):
export OPENROUTER_API_KEY="sk-or-..." # OpenRouter (any model)
export DEEPSEEK_API_KEY="sk-..." # DeepSeek first-partyCheck what a given model id resolves to with:
opencode models | grep <model>uv run coder-eval run tasks/opencode_smoke_test.yaml
uv run coder-eval run tasks/my_task.yaml -D agent.type=opencode -D agent.model=openrouter/deepseek/deepseek-v4-proagent:
type: "opencode"
# provider/model, exactly as `opencode models` prints it.
model: "openrouter/deepseek/deepseek-v4-pro"
permission_mode: "acceptEdits"
variant: "high" # optional: provider reasoning effort
pure: true # optional (default): run with --pure, no host pluginsagent.model is passed through verbatim to -m, so it must be OpenCode's
provider/model form. The provider prefix decides which credential is used:
agent.model |
Provider | Credential |
|---|---|---|
openrouter/deepseek/deepseek-v4-pro |
OpenRouter | OPENROUTER_API_KEY |
deepseek/deepseek-v4-pro |
DeepSeek direct | DEEPSEEK_API_KEY |
OpenCode speaks OpenRouter natively, so it does not need the LiteLLM proxy — that shim exists to translate Anthropic ↔ OpenAI for the Claude Code SDK.
Provider-specific reasoning effort (minimal / high / max, provider
dependent), forwarded as --variant. Omit to take the provider default.
Defaults to true, forwarding --pure so the sandbox is isolated from host-level
OpenCode plugin configuration. This mirrors the rationale behind the Claude agent's
setting_sources: []. Set to false to load host plugins deliberately.
--pure skips external plugins; it does not skip configured skill paths, so
skill injection (below) works under the default pure: true.
A plugins: entry is a Claude-plugin root, which is how a task ships the skills
under test:
agent:
plugins:
- type: "local"
path: "$SKILLS_REPO_PATH"OpenCode has no plugin knob, but it does load skills from skills.paths in its
config, so each local plugin root is mapped to that:
<root>/.claude-plugin/plugin.jsonis read for itsskillsfield (a string or a list, each relative to the root); Claude Code reads the same field, so oneplugins:line means the same thing on both harnesses.- Absent a manifest, the convention default
<root>/skillsis used. - A path that is already a bare skills directory (
<root>/<name>/SKILL.md, noskills/subdir) is used as-is.
The resulting directories are passed through OPENCODE_CONFIG_CONTENT, which the
CLI merges as a final local-scope config layer. That seam was chosen over writing
<sandbox>/.opencode/skills/ because it writes nothing into the sandbox that is
later preserved as a run artifact and inspected by file criteria, and because it
does not depend on how the CLI resolves a project root from --dir. An inherited
OPENCODE_CONFIG_CONTENT is merged into, not clobbered. With no plugins: entry
the variable is left exactly as inherited.
A plugin root is mapped to its skills subdirectory, never to the root itself when one exists.
skills.pathsis scanned recursively, and a plugin root can contain a self-referential symlink (UiPath/skillshasplugins/uipath -> ..), which resolves skills through an arbitrary path and silently drops duplicate names.
Verify what the agent will actually see, using the same environment it builds:
opencode debug skill --pure # lists every skill the CLI can loadEvery way this can resolve to nothing — an unset $SKILLS_REPO_PATH, a missing
directory, a root with no SKILL.md under it — is logged as a warning at start(),
and the resolved paths are recorded per task under environment_info
(opencode_skill_paths). A run that quietly measures the bare model instead of the
skills under test otherwise looks entirely normal.
Every permission_mode except plan passes --auto, auto-approving tool use.
This is required for unattended evaluation — without it OpenCode blocks on an
interactive approval prompt and the turn runs to its timeout. Use
permission_mode: plan when you explicitly want approvals withheld.
Mapping from the CLI's event vocabulary onto TurnRecord:
| OpenCode event | Becomes |
|---|---|
step_start |
TurnStartEvent (one inner turn) |
text |
TextChunkEvent + agent_output |
tool_use |
ToolStartEvent + ToolEndEvent (one terminal event carries both) |
step_finish |
TurnEndEvent + per-step tokens/cost, one AssistantMessage |
error |
AgentCrashError with the partial turn preserved |
Token buckets come from step_finish.tokens. Two conventions for tokens.input
exist in the wild, and the stream's own total arbitrates per step:
- flat (what the current CLI emits, verified live —
7966 = 6796 + 128 + 18 + 1024exactly):inputalready is the fresh slice, andtotal = input + output + reasoning + cache.read + cache.write; - nested (the OpenAI
prompt_tokensconvention): cached tokens are counted insideinput,total = input + output + reasoning, so the fresh slice isinput - cache.read - cache.write.
With no cache traffic the two agree. With no usable total the flat reading is
taken — logged as a warning when cache traffic is present, since the convention
then cannot be verified. A total matching neither also warns (once per
turn) that the bucket mapping may no longer match the CLI — so a drifting schema
is visible in task.log instead of quietly mis-costing every run. Reasoning
tokens are folded into output_tokens (they bill at the output rate) while
remaining visible as reasoning_tokens per message. This keeps the
reconciliation invariant exact: summing the four buckets across
TurnRecord.messages equals token_usage.
Real per-call cost rides on step_finish.cost and lands on
token_usage.total_cost_usd, so runs are costed from the provider's own
accounting rather than the static rate card. The rate card
(calculate_cost over the captured buckets) fills two gaps so the run total
never books tokens with no money: a stream that reports no cost at all (a
provider or auth mode that omits it, or a turn that died before its first
step_finish), and a stream that reports cost: 0 for tokens the rate
card prices above zero — OpenCode reports 0 when its own model registry has no
price for the model, or under subscription-style auth, and neither means the
tokens were free (the fallback logs a warning naming the substituted amount). A
non-zero cost the CLI reported always wins, and a genuinely free model still
resolves to $0 because its rate entry is absent or all-zero.
Tool names are normalized to the canonical (Claude) vocabulary on capture —
bash → Bash, read → Read, write/edit/patch → Write/Edit, and so
on; an unmapped tool keeps its own name. This is what lets one
command_executed criterion (which filters on tool_name and reads a Bash
call's command parameter) score identically whether the run used Claude, Codex
or OpenCode.
Tool argument keys are normalized the same way, because command_executed
serializes parameters to JSON for every tool but Bash — so a criterion like
{type: command_executed, tool_name: Read, command_pattern: 'file_path.*app\.py'}
would otherwise match on Claude and score 0 on OpenCode for identical behaviour:
| Canonical tool | OpenCode key | Recorded as |
|---|---|---|
Read / Write / Edit |
path (or filePath) |
file_path |
Edit |
oldString / newString / replaceAll |
old_string / new_string / replace_all |
Skill |
name |
skill |
Both file-path spellings are accepted because the CLI has moved between versions
(a 2026-08-13 capture emitted filePath; current builds register path).
Bash's command and the search tools' path already match Claude's names and
pass through untouched, as does every unlisted key.
The tool vocabulary varies by MODEL, not just by harness. OpenCode exposes a provider-specific tool set: a GPT-family model edits via
apply_patchwhere DeepSeek useswrite/edit.apply_patchis therefore mapped toWrite(ascodex_agentdoes), so onetool_name: Writecriterion reads the same on either. Its argument is a patch envelope (patchText), not a file path, so acommand_patternwritten againstfile_pathwill not match it — assert ontool_namealone, or on the resulting file withfile_exists/file_contains.
These event names are the CLI's own compact vocabulary. They are not the
session.next.*names in the OpenAPI schema served byopencode serve— that describes the HTTP/SSE surface and does not apply here.
Drift is crashed, not scored: a turn whose CLI exits cleanly but which captured
no token telemetry is failed rather than reported as a clean empty success.
File-based criteria could otherwise still pass on whatever the agent did, giving
a SUCCESS that silently vanishes from every token aggregate — and whose
run_limits.max_total_tokens / max_usd gates could never trip no matter what
the run really billed. Two shapes reach it:
- the stream contained no recognized events (an upgrade renamed the vocabulary) — the error names the unrecognized event types it saw;
- events were recognized but every finished step carried no usable token
counts (a provider or auth mode that omits
tokens) — the error reports the finished-step count and whether cost was present.
Intentional cuts (should_stop, max_turns) are exempt: both can land before
the first event, or between a step's start and its step_finish.
For a provider or auth mode that genuinely reports no usage — where failing every
turn would make the harness unusable rather than merely imprecise — set
require_token_telemetry: false (or -D agent.require_token_telemetry=false).
The turn is then warned about and scored. This relaxes only the missing-token
arm: a stream with no recognized events still fails, since no provider quirk
explains a renamed event vocabulary.
Not supported yet — use the default tempdir driver. Unlike the other
built-in agents, sandbox: {driver: docker} does not work with
agent: {type: opencode}, and the failure is loud rather than silent:
start() finds no opencode on PATH inside the container and every task dies
with the install hint, which you cannot act on because that PATH lives in an
image you did not build.
Two things are missing, both deliberate rather than overlooked:
- The CLI is not in the image.
docker/Dockerfilebakes inclaude-code(pinned) plus thecodexandantigravityextras. OpenCode is a Node CLI installed withnpm install -g opencode-ai, so shipping it means adding a pinned version that travels with the coder_eval release tag the wayCLAUDE_CODE_VERSIONdoes — a release-process decision, not a one-line edit. (Node 22 is already present in the image, so the change itself is small.) - No credentials would reach it. The docker driver forwards host environment
variables through an explicit allowlist (
SandboxConfig.env_passthrough), which carries per-harness blocks for Codex and Antigravity but none for OpenCode — soOPENROUTER_API_KEYand friends are not passed through, andopencode auth login's credential file is not mounted. Even a custom image with the CLI baked in would authenticate against nothing.
Until both land, run OpenCode tasks under tempdir (the default) on a host that
has the CLI and its provider credentials. If you need container isolation now,
build your own image from docker/Dockerfile with the npm install -g line
added and pass the credentials via sandbox.env_passthrough_extra.
allowed_tools/disallowed_tools/system_prompt/system_prompt_fileare not enforced. The CLI exposes no equivalent knob, so these are dropped —start()logs a warning naming each one it saw (experiments/default.yamlsetsallowed_toolson every task, so expect it on a default run). Do not rely on them as a boundary here.- Only the skills half of a
plugins:entry is honored (see below). A Claude plugin's agents, hooks, commands and MCP servers have no OpenCode equivalent and are still dropped. max_turnscounts OpenCode's native steps. One step = one assistant generation (step_start/step_finish) and may carry several tool calls;max_turns: Nallows N complete steps, then the run finalizes cleanly asmax_turns_exhausted. This is the claude-code-style native unit, not the visible-turn unit Codex/Antigravity use — see Run-Limit Parity before holdingmax_turnsconstant across harnesses.- The
dockersandbox driver is unsupported. The CLI is not in the image and no OpenCode credentials are in theenv_passthroughallowlist — see Running in Docker for the workaround and what it would take to close. - No sub-agent attribution. OpenCode's CLI stream does not expose nested agent generations, so per-sub-agent token grouping (available for Claude and Codex) is not derivable.
- Cooperative stop is at event granularity.
should_stopis polled between events and honored by terminating the CLI, sostop_earlyworks, but the cut lands on an event boundary rather than mid-tool. - Pipe and process teardown.
opencode runleaves a local server child holding the inherited stdout/stderr pipes, so EOF never arrives on its own. The agent races each read against process exit, bounds the post-exit drain, and bounds the final reap by the turn deadline (a CLI that closes its stream but never exits is cut as a timeout/crash, not waited out). stderr gets its own concurrent reader from the moment the CLI starts — draining it only afterwards would let a full stderr pipe block the child mid-write and stall stdout with it. Each invocation runs in its own process group (start_new_session), andkill()/kill_sync()/stop()sweep that group with SIGKILL, so the server child is reaped rather than leaked across a batch; OpenCode persists sessions on disk, so--sessioncontinuity survives the sweep.
No endpoints available matching your guardrail restrictions and data policy
— an OpenRouter account setting, not a Coder Eval problem. The model's serving
providers are all excluded by your account's privacy/guardrail configuration.
Verify independently with a direct API call, then adjust at
openrouter.ai/settings/privacy.
Task fails with captured zero token telemetry — the CLI exited cleanly but
the turn booked no tokens, so it would have scored with nothing in any aggregate;
the harness fails it instead. Check the raw stream with
opencode run --format json ... > raw.jsonl:
- if the error says no recognized events, compare the
typevalues against the table above — an OpenCode upgrade that renames them needs a matching harness update; - if it reports finished steps with no usable token counts, inspect a
step_finishpayload'stokensobject — a provider or auth mode that omits usage, or a renamed bucket, produces this.
- OpenCode docs · CLI reference
- Extending Coder Eval — the agent plugin SPI
- Task Definition Guide