Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ HarnessRouter implements the [Unified Harness Protocol (UHP)](#the-unified-harne

Eight harness × model configurations on the same task. Each metric compares its best and worst results. The lowest-cost and fastest configurations vary by task. [Methodology](https://harnessrouter.ai/benchmarks)

**30 harness bases today:** Codex, Claude Code, Hermes, Pi, DeepSeek Harness, OpenCode, Kilo Code, Qwen Code, Gemini CLI, Cline, Oh My Pi, goose, Kimi Code CLI, MiniMax Code, Aider, OpenHands, CheetahClaws, Grok Build, Agent Zero, System One, Qoder Cloud Agent, MiMo Code, jcode, OpenSquilla, nanobot, ZeroClaw, Mistral Vibe, Docker Agent, fast-agent and Deep Agents Code. The [support matrix](docs/support-matrix.md) shows which run on which provider.
**31 harness bases today:** Codex, Claude Code, Hermes, Pi, DeepSeek Harness, OpenCode, Kilo Code, Qwen Code, Gemini CLI, Cline, Oh My Pi, goose, Kimi Code CLI, MiniMax Code, Aider, OpenHands, CheetahClaws, Grok Build, Agent Zero, System One, Qoder Cloud Agent, MiMo Code, jcode, OpenSquilla, nanobot, ZeroClaw, Mistral Vibe, Docker Agent, fast-agent, Deep Agents Code and Roder. The [support matrix](docs/support-matrix.md) shows which run on which provider.

> [!TIP]
> **Get started:** [Run locally](#quickstart) · [Integrate into your product](#use-the-api-directly) · [Starter kits](#starter-kits) · [Use managed Cloud](https://harnessrouter.ai)
Expand Down
31 changes: 30 additions & 1 deletion docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export NODE_PATH="$TOOLS/lib/node_modules"
# did not opt into is a row that fails on its first turn with no explanation. Disk is the
# trade — jcode is a ~127 MB static Rust binary and the two Python bases bring their own venvs —
# and an operator who wants them out sets HR_BACKENDS.
export HR_BACKENDS="${HR_BACKENDS:-claude,codex,hermes,pi,dsh,opencode,kilo,qwen,gemini,cline,omp,goose,kimi,minimax,grok,aider,openhands,agentzero,systemone,cheetahclaws,mimo,jcode,opensquilla,nanobot,zeroclaw,qoder,vibe,docker-agent,fast-agent,dcode}"
export HR_BACKENDS="${HR_BACKENDS:-claude,codex,hermes,pi,dsh,opencode,kilo,qwen,gemini,cline,omp,goose,kimi,minimax,grok,aider,openhands,agentzero,systemone,cheetahclaws,mimo,jcode,opensquilla,nanobot,zeroclaw,qoder,vibe,docker-agent,fast-agent,dcode,roder}"

wanted() { [[ ",$HR_BACKENDS," == *",$1,"* ]]; }
# The executable IS the definition of "installed" — an installer that exits 0 without producing
Expand Down Expand Up @@ -249,6 +249,7 @@ backend_bin() {
# interpreter is what "installed" means here.
qoder) echo "$TOOLS/qoder-venv/bin/python" ;;
mimo) echo "$TOOLS/bin/mimo" ;;
roder) echo "$TOOLS/bin/roder" ;;
jcode) echo "$TOOLS/bin/jcode" ;;
opensquilla) echo "$TOOLS/opensquilla-venv/bin/opensquilla" ;;
vibe) echo "$TOOLS/vibe-venv/bin/vibe" ;;
Expand Down Expand Up @@ -1034,6 +1035,29 @@ install_mimo() {
fi
}

# Roder publishes checksums beside its Linux binaries, like jcode below.
install_roder() {
case "$(uname -m)" in
x86_64) rd_arch="x86_64" ;;
aarch64|arm64) rd_arch="aarch64" ;;
*) echo "unsupported architecture for Roder: $(uname -m)"; return 1 ;;
esac
rd_base="https://github.com/RoderAI/roder/releases/download/latest"
rd_asset="roder-$rd_arch-unknown-linux-gnu"
rd_tmp="$(mktemp -d)"
curl -fsSL "$rd_base/$rd_asset" -o "$rd_tmp/roder" || { rm -rf "$rd_tmp"; return 1; }
curl -fsSL "$rd_base/SHA256SUMS" -o "$rd_tmp/SHA256SUMS" || { rm -rf "$rd_tmp"; return 1; }
rd_want="$(awk -v asset="$rd_asset" '$2 == asset {print $1}' "$rd_tmp/SHA256SUMS")"
rd_have="$(sha256sum "$rd_tmp/roder" | awk '{print $1}')"
if [ -z "$rd_want" ] || [ "$rd_want" != "$rd_have" ]; then
echo "Roder: checksum mismatch for $rd_asset (want ${rd_want:-none}, have $rd_have)"
rm -rf "$rd_tmp"; return 1
fi
mkdir -p "$TOOLS/bin"
install -m 755 "$rd_tmp/roder" "$TOOLS/bin/roder" || { rm -rf "$rd_tmp"; return 1; }
rm -rf "$rd_tmp"
}

# jcode (MIT, 1jehuang/jcode) publishes one prebuilt archive per target with a SHA256SUMS file, so
# it is fetched and compared the same way. v0.90.0 is the release _build_jcode and _jcode_to_claude
# were measured against — the NDJSON event names, the per-request `tokens` accounting, the resume
Expand Down Expand Up @@ -1570,6 +1594,11 @@ install_backends() {
try_install "MiMo Code" install_mimo || true
fi

if wanted roder && [ ! -x "$(backend_bin roder)" ]; then
echo "[harnessrouter] installing Roder (Apache-2.0)…"
try_install "Roder" install_roder || true
fi

JCODE_PIN="${HR_JCODE_VERSION:-0.90.0}"
# `jcode --version` prints `jcode v<version> (<commit>)`; the version field is what is pinned.
if wanted jcode && [ "$("$(backend_bin jcode)" --version 2>/dev/null | awk '{print $2}')" != "v$JCODE_PIN" ]; then
Expand Down
77 changes: 77 additions & 0 deletions docs/roder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Roder

[Roder](https://github.com/RoderAI/roder) runs through its native `roder app-server`
JSON-RPC interface. Like Codex's app-server integration, each turn starts a process,
initializes it, creates or reads a thread, and starts a turn with an explicit model.
The process ends after `turn/completed`; thread history stays in the workspace.

## Connections

Use an OpenAI, Azure Foundry, OpenRouter, Vercel, LLMTR, TokenRouter, HarnessRouter,
or custom **Responses** connection. This adapter uses Roder's custom Responses
provider `hr`, preserving the requested model id. It does not select Roder's Codex OAuth provider. The catalog
includes the current GPT, Claude, Gemini, Grok, DeepSeek, Kimi, Qwen, and other
model families, with `gpt-6.1-sol` as the default. Availability depends on the
selected connection; each provider serves its own subset. Anthropic Messages
and Chat Completions custom endpoints are not offered by this adapter.

Every provider call uses the existing loopback relay. Roder receives a relay
placeholder in `HR_RODER_KEY`; the provider key stays in the runner. The relay
collects provider-reported model and usage independently of the CLI.

## Installation

Include `roder` in `HR_BACKENDS` (it is included by default). The entrypoint installs
the Linux binary from Roder's `latest` release when it is missing, then verifies
it against the release's published `SHA256SUMS`, following the jcode installer
pattern. Existing installations are reused. No auto-update runs inside a turn.

`HR_RODER_BIN` can select an existing binary for development and process tests.

## Sessions, tools, and skills

- `RODER_CONFIG_DIR` and `RODER_DATA_DIR` point at `.harness/roder/`. Persisted
threads ride the normal checkpoint; `auth/` and the per-turn `config.toml` are
excluded because CLI configuration/auth commands can write credentials there.
- Every `turn/start` carries the requested model and provider, including resumed
threads. Missing history emits `system/resume_lost` before starting a new thread.
- The harness instruction file is `AGENTS.md`; the driver sends its current contents
as `turn/start.developerContext` on every turn. Skills are installed under
`.harness/home/.codex/skills`, Roder's user skill root, keeping bundles outside
produced files.
- Disabled native tools are withheld using Roder's runtime `tools.allowlist` on
every process, including resumes. A short discovery process obtains the tools
for the requested model before the turn process starts. Disabling one tool does
not disable other tools that can accomplish the same task.
- MCP supports stdio, SSE, and streamable HTTP through the existing MCP Python
dependency. The driver publishes discovered tools as Roder `externalTools` and
answers `thread/toolExecutionRequested` via `tools/resolve`. Unreachable servers
produce `system/mcp_unavailable`. An attachment/schema change on an existing
thread is refused with an instruction to start a new session: Roder persists
external tools at thread creation and has no API to replace them.
- Timeout, cancellation, and step budgets use the runner's existing process-group
controls. A truncated stream fails even when its process exits zero.

## Verification

Run the unit and registration tests:

```sh
python -m pytest runner/tests/test_roder_backend.py
python -m pytest gateway/tests/test_roder_catalog.py gateway/tests/test_instruction_file_label.py
```

Qualify a real binary against a deterministic local Responses server:

```sh
HR_RODER_TEST_BIN=/path/to/roder python -m pytest runner/tests/test_roder_process.py
```

These process tests check streaming, follow-up history, model switching, process
restart, lost-session reporting, native tool policy, provider errors, artifact
creation, and stdio MCP execution without provider credentials or inference costs.
They do **not** establish a live provider support-matrix column, console artifact
rendering parity, the family tour, or the benchmark dimension. Run
`scripts/support-matrix` and its custom-harness scenario on an isolated instance
before publishing a verified provider column. Roder is included in that suite's
base list; no existing provider results have been copied or marked passing.
8 changes: 8 additions & 0 deletions docs/self-hosting-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1341,3 +1341,11 @@ Apache-2.0, see [LICENSE](../LICENSE). Third-party notices are in [NOTICE](../NO

The agent CLIs are **not** redistributed here; they are installed on first run under their own
licenses. Review them before enabling a backend.


### Roder

Set `HR_BACKENDS=roder` for a Roder-only installation. Roder uses its native app-server
and a Responses connection (OpenAI, Azure Foundry, OpenRouter, Vercel, LLMTR, TokenRouter, HarnessRouter, or a custom
endpoint with `api_format=responses`). See [Roder setup and verification](roder.md) for
installation, session state, tool policy, MCP, and qualification limits.
8 changes: 8 additions & 0 deletions docs/support-matrix-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5508,3 +5508,11 @@ specified API usage limits"), not a result. It was run again on this instance's
A deck conversation that changes model family on every turn passed 5 of 5: deepseek-v4-pro made `deck.pptx`,
claude-sonnet-5 made it red, gemini-3.8-flash made the title bold at 40 pt, gemini-3.6-flash added a slide (one
Gemini model after another) and gpt-5.4 set every bullet to 20 pt. The deck was read back with each change in it.


## Roder — adapter qualification

The native app-server adapter is documented in [roder.md](roder.md). Its deterministic
process suite covers the released binary's Responses and JSON-RPC contracts. Live provider
columns, custom-harness measurement, the family tour, browser/environment columns, and
benchmarks have not been measured. No other harness's verified results apply to Roder.
4 changes: 4 additions & 0 deletions docs/support-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -7845,3 +7845,7 @@ Measured 2026-09-27 on hr-test (self-hosted, 0.25.7-rc.17). One task in plain wo

15 of 15 bases drive the browser.



Roder's new native app-server adapter has deterministic process qualification only;
its live provider matrix is pending. See [Roder](roder.md) for scope and commands.
18 changes: 17 additions & 1 deletion gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1672,6 +1672,10 @@ def _conn_public(conn: dict) -> dict:
# jcode and opensquilla also have an Anthropic-Messages provider, used only for a connection
# that IS Anthropic — a claude id on an aggregator stays on the relay so its served model
# remains readable (see _build_jcode).
("openai", "roder"): "openai", ("azure-foundry", "roder"): "azure",
("tokenrouter", "roder"): "tokenrouter", ("custom", "roder"): "tokenrouter",
("harnessrouter", "roder"): "tokenrouter", ("openrouter", "roder"): "tokenrouter",
("vercel", "roder"): "tokenrouter", ("llmtr", "roder"): "tokenrouter",
("anthropic", "jcode"): "anthropic", ("openai", "jcode"): "openai",
("azure-foundry", "jcode"): "azure",
("openrouter", "jcode"): "openai-api",
Expand Down Expand Up @@ -6012,7 +6016,7 @@ def _provider_backends(provider: str) -> list[str]:
# langchain-openai model posts to /v1/responses on every turn (measured through a recording
# proxy and again through the runner's own relay). A custom integration declaring
# chat/completions cannot serve it, which is why dcode is in this set and in neither other one.
"responses": {"codex", "dcode"},
"responses": {"codex", "dcode", "roder"},
}


Expand Down Expand Up @@ -8199,6 +8203,9 @@ def _valid_default() -> str | None:
# CHAT_ONLY_BACKENDS below. (jcode and opensquilla also have an Anthropic-Messages provider, used
# only for a connection that IS Anthropic; the runner keeps a claude id on an aggregator on the
# relay so its served model stays readable — see _build_jcode.)
# Roder's Responses provider preserves model ids across families. Offer the maintained
# cross-provider catalog; live verification results remain specific to Roder.
_MODEL_CATALOG["roder"] = {"default": "gpt-6.1-sol", "models": list(_MODEL_CATALOG["opencode"]["models"])}
# vibe (mistral-vibe) is the same shape: its `generic` backend is an OpenAI chat/completions
# client and every keyed turn rides the relay, so its wire reach is kimi's. MEASURED on it so far
# (2026-10-14/15, an OpenAI-compatible gateway through a recording proxy): claude-haiku-5.5 and
Expand Down Expand Up @@ -18898,6 +18905,15 @@ def _builtin_default_skills(seen: set[str] | None = None) -> list[dict]:
# capability — the same honest limit every shell-bearing base carries.
"tool_enforcement": "hard",
},
"roder": {
"label": "Roder", "backend": "roder", "status": "ready",
"system_prompt": "You are Roder, an autonomous coding agent. Complete the task in the workspace.",
"tools": [("exec_command", "Shell"), ("read_file", "File Read"),
("write_file", "File Write"), ("edit", "Edit"), ("apply_patch", "Apply Patch"),
("grep", "Search"), ("glob", "Glob"), ("list_files", "List Files"),
("update_plan", "Plan")],
"tool_enforcement": "hard",
},
"opensquilla": {
"label": "OpenSquilla", "backend": "opensquilla", "status": "ready",
"system_prompt": ("You are OpenSquilla, an autonomous agent. You work on a real git "
Expand Down
6 changes: 4 additions & 2 deletions gateway/tests/test_default_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ def test_the_console_preselects_the_catalogs_default():
def test_gpt_6_1_sol_is_the_default_where_gpt_5_4_was_and_it_runs():
sol = sorted(b for b, e in gw._MODEL_CATALOG.items() if e["default"] == "gpt-6.1-sol")
# where it ran all five matrix scenarios through OpenAI, Azure and TokenRouter (2026-10-22; dcode 2026-10-27)
assert sol == sorted(["codex", "hermes", "pi", "opencode", "kilo", "omp", "goose", "aider", "agentzero", "mimo",
"dcode"])
measured = {"codex", "hermes", "pi", "opencode", "kilo", "omp", "goose", "aider", "agentzero", "mimo",
"dcode"}
# Roder uses this default; its live matrix qualification remains separate.
assert sol == sorted(measured | {"roder"})
# refused it on a common connection (chat/completions with function tools, at any effort), or not offered it
for b in ("cline", "cheetahclaws", "jcode", "nanobot", "zeroclaw", "openhands", "opensquilla",
"vibe", "docker-agent", "fast-agent"):
Expand Down
3 changes: 2 additions & 1 deletion gateway/tests/test_muse_wonder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def test_it_leads_the_meta_family_wherever_it_passed_and_is_off_where_it_failed(
# 21 since batch 4: vibe, docker-agent, fast-agent and dcode each take kimi's list, so they
# offer the Meta family for the same reason every other id on them is offered — the wire reach
# implies it. No column has run on those four, which docs/support-matrix-notes.md says.
assert len(offered) == 21, sorted(offered)
assert len(offered) == 22, sorted(offered)
assert "roder" in offered
assert all("muse-wonder-1.0" in gw._NOT_OFFERED[b] for b in ("agentzero", "aider", "openhands"))
assert gw._MODEL_ORDER.count("muse-wonder-1.0") == 1
32 changes: 32 additions & 0 deletions gateway/tests/test_roder_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Only Responses connections may be offered to the Roder native app-server adapter."""
import os
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
os.environ.setdefault("HR_BACKING", "local")
import app as A


def test_roder_registration_is_responses_only():
assert A._BASE_CATALOG["roder"]["backend"] == "roder"
assert A._BASE_CATALOG["roder"]["tool_enforcement"] == "hard"
assert "roder" in A._CUSTOM_FORMAT_BACKENDS["responses"]
assert "roder" not in A._CUSTOM_FORMAT_BACKENDS["openai"]
assert "roder" not in A._CUSTOM_FORMAT_BACKENDS["anthropic"]
assert A._INTEGRATION_WIRING[("openai", "roder")] == "openai"
assert A._INTEGRATION_WIRING[("azure-foundry", "roder")] == "azure"
assert ("anthropic", "roder") not in A._INTEGRATION_WIRING
assert A._backend_of_builtin("roder") == "roder"


def test_roder_offers_current_model_families_and_default():
models = A._MODEL_CATALOG["roder"]["models"]
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "runner"))
import roder_backend
assert A._MODEL_CATALOG["roder"]["default"] == roder_backend.DEFAULT_MODEL == "gpt-6.1-sol"
assert {"gpt-6.1-sol", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna",
"claude-fable-5-1", "claude-opus-5.5", "gemini-4-argon",
"grok-4.6", "deepseek-v4.1-flash", "kimi-k3", "qwen3.8-max"} <= set(models)
for provider in ("openrouter", "vercel", "llmtr", "tokenrouter", "harnessrouter"):
assert A._INTEGRATION_WIRING[(provider, "roder")] == "tokenrouter"
54 changes: 54 additions & 0 deletions runner/roder_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Roder's native app-server backend. Provider traffic stays on the runner relay."""
import json
import pathlib
import sys

PROVIDERS = {"openai", "azure", "tokenrouter"}
DEFAULT_MODEL = "gpt-6.1-sol"


def build(provider, auth, model, prompt, cwd, env, *, relay, resume_session_id=None,
mcp_servers=None, tools_disabled=None, thinking=None):
from fastapi import HTTPException
if provider not in PROVIDERS:
raise HTTPException(400, f"unknown roder provider '{provider}'")
if auth.api_format and auth.api_format != "responses":
raise HTTPException(400, "roder requires a Responses-format endpoint")
base = auth.base_url or {"openai": "https://api.openai.com/v1"}.get(provider)
if not base or not auth.api_key:
raise HTTPException(400, "roder needs a base_url and API key")
base, token = relay(base, auth.api_key)
root = pathlib.Path(cwd) / ".harness" / "roder"
root.mkdir(parents=True, exist_ok=True)
env.update(RODER_CONFIG_DIR=str(root), RODER_DATA_DIR=str(root), HR_RODER_KEY=token)
# Roder custom providers speak Responses. The id is ours, never the built-in Codex provider
# (which would use OAuth); the wire model id is preserved exactly, including vendor slashes.
config = (f'provider = "hr"\nmodel = {json.dumps(model)}\nruntime_profile = "non_interactive"\n'
'[providers.hr]\napi_key_env = "HR_RODER_KEY"\n'
f'base_url = {json.dumps(base)}\n'
'[policy_modes]\ndefault = "bypass"\nwarn_on_bypass = false\n')
(root / "config.toml").write_text(config)
env["HR_RODER_JOB"] = json.dumps({"model": model, "prompt": prompt,
"resume": resume_session_id, "tools_disabled": tools_disabled or [],
"mcp_servers": mcp_servers or [], "reasoning": (thinking or {}).get("asked")})
return [sys.executable, str(pathlib.Path(__file__).with_name("roder_driver.py"))]


def normalize(obj, state):
"""The driver already emits the same canonical events as Codex's app-server path."""
if obj.get("type") == "result" and not obj.get("is_error"):
state["final"] = obj.get("result") or ""
if obj.get("type") == "assistant":
for block in obj.get("message", {}).get("content", []):
if block.get("type") == "text":
state["final"] = state.get("final", "") + block["text"]
return [obj]


def eof(state, rc):
# Unlike the generic runner's exit-zero fallback, a truncated app-server stream never passes.
return [{"type": "result", "subtype": "error", "is_error": True,
"result": f"Roder exited without turn/completed (exit {rc})"}]


normalize.eof = eof
Loading