Skip to content
Merged
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ flowchart LR
### Run Switchyard as a standalone proxy

A server in front of an agent, when you have no gateway to put Switchyard in.
Point Claude Code, Codex CLI, or any OpenAI/Anthropic SDK client at it;
Switchyard decides per turn which model serves it.
Point Claude Code, Codex CLI, pi, Oh My Pi, or any OpenAI/Anthropic SDK client
at it; Switchyard decides per turn which model serves it.

- Install: `cargo install --locked switchyard-server`
- Then follow [Path 3 — Run the Standalone Proxy](#path-3--run-the-standalone-proxy):
Expand Down Expand Up @@ -260,6 +260,13 @@ codex --model switchyard -c 'model_provider="switchyard"'
No Codex API key is needed for this local setup. Switchyard uses the server's
`OPENROUTER_API_KEY` for upstream requests.

For pi, add a `switchyard` provider to `~/.pi/agent/models.json`. For Oh My Pi, add
the same provider to `~/.omp/agent/models.yml`. Both entries set `baseUrl` to the
server address and list the route id `switchyard` as a model.
[Use Switchyard with pi](docs/integrations/pi.md) and
[Use Switchyard with Oh My Pi](docs/integrations/oh_my_pi.md) give the exact entries,
say which request API to pick, and show how to confirm the routing.

## Routing Algorithms

Start with **Auto**. Choose Task or Execution when you want more control over
Expand Down
9 changes: 5 additions & 4 deletions benchmark/DATASETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,11 @@ For each task, the rewriter prepares an image with pinned agent tooling:
- If the task already has `environment/Dockerfile`, it appends the pinned agent install layer.
- If neither exists, it creates a minimal `environment/Dockerfile` from `ubuntu:22.04`.

The injected layer installs the pinned Node, Claude Code, Codex, and OpenCode versions from
`benchmark/agent-versions.env`, then runs `claude --version`, `codex --version`, and
`opencode --version` during image build. The base image must support `apt-get`, `apk`, or `yum`, or
already provide `curl`, `tar`, and `gzip`. It must be `x86_64/amd64` or `aarch64/arm64`.
The injected layer installs the pinned Node, Claude Code, Codex, OpenCode, and pi versions from
`benchmark/agent-versions.env`, then runs `claude --version`, `codex --version`,
`opencode --version`, and `pi --version` during image build. The base image must support `apt-get`,
`apk`, or `yum`, or already provide `curl`, `tar`, and `gzip`. It must be `x86_64/amd64` or
`aarch64/arm64`.

For non-TB datasets, inspect generated Dockerfiles before a full run:

Expand Down
24 changes: 24 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,30 @@ bash benchmark/run-baseline.sh \
Tune `--n-concurrent` for your machine and provider quota. Use `--task-id`, `--task-list-file`, or
`--n-tasks` for subsets.

### Run with the pi coding agent

```bash
bash benchmark/run-baseline.sh \
--harbor-path benchmark/datasets/openthoughts-tblite-closed-book \
--server-config benchmark/server-configs/tb-lite-llm-classifier-opus-kimi-gemini.toml \
--agent pi \
--model switchyard \
--reasoning-effort high \
--harbor-extra --ae --harbor-extra PI_CONTEXT_WINDOW=200000 \
--harbor-extra --ae --harbor-extra PI_MAX_OUTPUT_TOKENS=32000 \
--n-concurrent 8 \
--max-retries 2
```

With `--server-config`, the script passes the model label `switchyard/<route>` to Harbor's pi
agent. The patched agent then writes `~/.pi/agent/models.json` inside the task container. That file
defines a `switchyard` provider that points at `OPENAI_BASE_URL` and uses the `openai-completions`
API. The agent environment variables `PI_CONTEXT_WINDOW` and `PI_MAX_OUTPUT_TOKENS` set
`contextWindow` and `maxTokens` on that model entry. When they are unset, pi uses its defaults of
128000 and 16384. `--reasoning-effort` sets pi's `--thinking` level, so pass one of `off`,
`minimal`, `low`, `medium`, `high`, or `xhigh`. Without `--server-config`, pass pi's own provider
label as `--model`, for example `openrouter/openai/gpt-5.5`.

## Inspect A Run

Run directories are created under `benchmark/tb_runs/`. The most useful artifacts are:
Expand Down
2 changes: 2 additions & 0 deletions benchmark/agent-versions.env
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
CLAUDE_CODE_VERSION=2.1.211
CODEX_VERSION=0.144.5
OPENCODE_VERSION=1.18.3
# pi coding agent (npm package @earendil-works/pi-coding-agent).
PI_VERSION=0.84.3
NODE_VERSION=20.11.1
# Hermes (NousResearch hermes-agent), installed from GitHub at dataset-bake time.
# Must be a full 40-character commit SHA; anything else is rejected at build time.
Expand Down
150 changes: 149 additions & 1 deletion benchmark/patches/harbor-agent-patches.diff
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,154 @@
f"opencode --model={self.model_name} run --format=json {cli_flags_arg}--thinking --dangerously-skip-permissions -- {escaped_instruction} "
f"2>&1 </dev/null | stdbuf -oL tee /logs/agent/opencode.txt"
),
--- a/harbor/agents/installed/pi.py
+++ b/harbor/agents/installed/pi.py
@@ -14,6 +14,8 @@

class Pi(BaseInstalledAgent):
_OUTPUT_FILENAME = "pi.txt"
+ # Source nvm only if it exists: the prebaked dataset image installs pi on PATH without nvm.
+ _NVM_PRELUDE = '[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"; '

CLI_FLAGS = [
CliFlag(
@@ -29,7 +31,7 @@
return AgentName.PI.value

def get_version_command(self) -> str | None:
- return ". ~/.nvm/nvm.sh; pi --version"
+ return f"{self._NVM_PRELUDE}pi --version"

def parse_version(self, stdout: str) -> str:
return stdout.strip().splitlines()[-1].strip()
@@ -37,7 +39,14 @@
async def install(self, environment: BaseEnvironment) -> None:
await self.exec_as_root(
environment,
- command="apt-get update && apt-get install -y curl",
+ command=(
+ # Skip if pi is already installed (e.g. from agent-bake layer).
+ "if command -v pi >/dev/null 2>&1; then"
+ f" echo 'pi {self._version or 'unknown'} already installed, skipping system pkg install';"
+ " exit 0;"
+ "fi;"
+ "apt-get update && apt-get install -y curl"
+ ),
env={"DEBIAN_FRONTEND": "noninteractive"},
)
version_spec = f"@{self._version}" if self._version else "@latest"
@@ -45,12 +54,17 @@
environment,
command=(
"set -euo pipefail; "
+ # Skip if pi is already installed (e.g. from agent-bake layer).
+ "if command -v pi >/dev/null 2>&1; then"
+ f" echo 'pi {self._version or 'unknown'} already installed, skipping install';"
+ " exit 0;"
+ "fi;"
"curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && "
'export NVM_DIR="$HOME/.nvm" && '
'\\. "$NVM_DIR/nvm.sh" || true && '
"command -v nvm &>/dev/null || { echo 'Error: NVM failed to load' >&2; exit 1; } && "
"nvm install 22 && npm -v && "
- f"npm install -g @mariozechner/pi-coding-agent{version_spec} && "
+ f"npm install -g @earendil-works/pi-coding-agent{version_spec} && "
"pi --version"
),
)
@@ -65,6 +79,46 @@
f"$HOME/.agents/skills/ 2>/dev/null || true"
)

+ def _build_models_json_command(self, provider: str, model_id: str) -> str | None:
+ """Return a shell command that writes ~/.pi/agent/models.json for Switchyard.
+
+ pi has no built-in "switchyard" provider and does not read OPENAI_BASE_URL, so this
+ command adds the gateway as a custom provider before pi starts.
+ """
+ if provider != "switchyard":
+ return None
+ base_url = self._get_env("OPENAI_BASE_URL")
+ if not base_url:
+ raise ValueError("--model switchyard/<route> requires OPENAI_BASE_URL")
+ model_entry: dict[str, object] = {
+ "id": model_id,
+ "name": f"{model_id} (Switchyard)",
+ "reasoning": True,
+ }
+ context_window = self._get_env("PI_CONTEXT_WINDOW")
+ if context_window:
+ model_entry["contextWindow"] = int(context_window)
+ max_output_tokens = self._get_env("PI_MAX_OUTPUT_TOKENS")
+ if max_output_tokens:
+ model_entry["maxTokens"] = int(max_output_tokens)
+ models_config = {
+ "providers": {
+ "switchyard": {
+ "baseUrl": base_url,
+ "api": "openai-completions",
+ # Keep the literal string "$OPENAI_API_KEY"; pi expands the variable itself.
+ "apiKey": "$OPENAI_API_KEY",
+ "compat": {"supportsDeveloperRole": False},
+ "models": [model_entry],
+ }
+ }
+ }
+ # shlex.quote wraps the JSON in single quotes so the shell does not expand "$OPENAI_API_KEY".
+ return (
+ 'mkdir -p "$HOME/.pi/agent" && '
+ f'echo {shlex.quote(json.dumps(models_config))} > "$HOME/.pi/agent/models.json"'
+ )
+
@with_prompt_template
async def run(
self,
@@ -77,7 +131,7 @@
if not self.model_name or "/" not in self.model_name:
raise ValueError("Model name must be in the format provider/model_name")

- provider, _ = self.model_name.split("/", 1)
+ provider, model_id = self.model_name.split("/", 1)

env: dict[str, str] = {}
keys: list[str] = []
@@ -106,7 +160,7 @@
keys.append("HF_TOKEN")
elif provider == "mistral":
keys.append("MISTRAL_API_KEY")
- elif provider == "openai":
+ elif provider in ("openai", "switchyard"):
keys.append("OPENAI_API_KEY")
elif provider == "openrouter":
keys.append("OPENROUTER_API_KEY")
@@ -123,9 +177,7 @@
if val:
env[key] = val

- model_args = (
- f"--provider {provider} --model {self.model_name.split('/', 1)[1]} "
- )
+ model_args = f"--provider {provider} --model {model_id} "

cli_flags = self.build_cli_flags()
if cli_flags:
@@ -134,11 +186,14 @@
skills_command = self._build_register_skills_command()
if skills_command:
await self.exec_as_agent(environment, command=skills_command)
+ models_json_command = self._build_models_json_command(provider, model_id)
+ if models_json_command:
+ await self.exec_as_agent(environment, command=models_json_command)

await self.exec_as_agent(
environment,
command=(
- f". ~/.nvm/nvm.sh; "
+ f"{self._NVM_PRELUDE}"
f"pi --print --mode json --no-session "
f"{model_args}"
f"{cli_flags}"

--- a/harbor/models/trial/config.py
+++ b/harbor/models/trial/config.py
@@ -219,4 +219,5 @@
Expand All @@ -425,7 +573,7 @@
--- /dev/null
+++ b/harbor/switchyard_patch_id.txt
@@ -0,0 +1 @@
+switchyard-harbor-patches-2026-05-22-v2
+switchyard-harbor-patches-2026-09-22-v3

--- a/harbor/agents/installed/hermes.py
+++ b/harbor/agents/installed/hermes.py
Expand Down
18 changes: 12 additions & 6 deletions benchmark/prepare_harbor_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

"""Prepare a local closed-book Harbor dataset with prebaked coding agents."""

from __future__ import annotations

import argparse
import hashlib
import json
Expand Down Expand Up @@ -200,6 +198,7 @@ def _install_layer(pins: dict[str, str]) -> str:
claude_version = pins["CLAUDE_CODE_VERSION"]
codex_version = pins["CODEX_VERSION"]
opencode_version = pins["OPENCODE_VERSION"]
pi_version = pins["PI_VERSION"]
# Hermes (NousResearch hermes-agent) is a per-user uv app installed from
# GitHub, not an npm package. Baking it here (build-time, with host network)
# means the runtime install() skip-guard short-circuits, so tasks need no
Expand Down Expand Up @@ -228,7 +227,7 @@ def _install_layer(pins: dict[str, str]) -> str:
return f"""

# Switchyard benchmark prebaked coding agents.
ENV SWITCHYARD_PREBAKED_AGENT_VERSIONS="claude-code={claude_version},codex={codex_version},opencode={opencode_version},node={node_version},hermes={hermes_version}"
ENV SWITCHYARD_PREBAKED_AGENT_VERSIONS="claude-code={claude_version},codex={codex_version},opencode={opencode_version},pi={pi_version},node={node_version},hermes={hermes_version}"
RUN set -eux; \\
if command -v apt-get >/dev/null 2>&1; then \\
apt-get update; \\
Expand Down Expand Up @@ -265,10 +264,12 @@ def _install_layer(pins: dict[str, str]) -> str:
npm install -g \\
"@anthropic-ai/claude-code@{claude_version}" \\
"@openai/codex@{codex_version}" \\
"opencode-ai@{opencode_version}"; \\
"opencode-ai@{opencode_version}" \\
"@earendil-works/pi-coding-agent@{pi_version}"; \\
claude --version; \\
codex --version; \\
opencode --version
opencode --version; \\
pi --version
RUN set -eux; \\
export HOME=/root; \\
export PATH="/root/.local/bin:$PATH"; \\
Expand Down Expand Up @@ -506,20 +507,25 @@ def _merge_compose(task_dir: Path, proxy_allowlist_hosts: tuple[str, ...]) -> di


def prepare_dataset(
*,
source_dataset: str,
source_dir: Path | None,
output_dir: Path,
harbor_command: str,
overwrite: bool,
) -> Path:
"""Copy a Harbor dataset to `output_dir` and bake the pinned agents into its task images.

Every agent pin in `benchmark/agent-versions.env` must be present; a missing pin raises
`ValueError` before any download or copy starts.
"""
pins = _read_env_file(AGENT_VERSIONS_FILE)
required = {
"CLAUDE_CODE_VERSION",
"CODEX_VERSION",
"HERMES_VERSION",
"NODE_VERSION",
"OPENCODE_VERSION",
"PI_VERSION",
}
missing = sorted(required - pins.keys())
if missing:
Expand Down
20 changes: 15 additions & 5 deletions benchmark/run-baseline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fi
CLAUDE_CODE_VERSION="${CLAUDE_CODE_VERSION:-2.1.211}"
CODEX_VERSION="${CODEX_VERSION:-0.144.5}"
OPENCODE_VERSION="${OPENCODE_VERSION:-1.18.3}"
PI_VERSION="${PI_VERSION:-0.84.3}"
NODE_VERSION="${NODE_VERSION:-20.11.1}"

DEFAULT_HARBOR_MODEL="openai/gpt-5.2"
Expand Down Expand Up @@ -97,8 +98,9 @@ Main options:
--server-config this is a Switchyard route
key; without it, this is the upstream model.
Defaults --harbor-model to nvidia/MODEL for
opencode, openai/MODEL for other OpenAI-style
agents, and MODEL for claude-code/codex.
opencode, switchyard/MODEL for pi, openai/MODEL
for other OpenAI-style agents, and MODEL for
claude-code/codex.
--route-model MODEL Deprecated alias for --model in Switchyard mode.
--agent NAME Harbor agent (default: terminus-2)
--harbor-model MODEL Explicit Harbor model label override.
Expand All @@ -111,7 +113,8 @@ Main options:
--book-mode MODE closed or open (default: closed). Both modes use
the generated dataset proxy topology; open mode
allows broad egress through the proxy.
--reasoning-effort VALUE Forwarded as --ak reasoning_effort=VALUE.
--reasoning-effort VALUE Forwarded as --ak reasoning_effort=VALUE
(--ak thinking=VALUE for pi).
Defaults by agent/model; pass an empty value to omit.
--harbor-bin PATH Optional Harbor executable override
(default: uv run --no-sync harbor)
Expand Down Expand Up @@ -288,6 +291,7 @@ add_agent_version_kwarg() {
claude-code) HARBOR_CMD+=(--ak "version=${CLAUDE_CODE_VERSION}") ;;
codex) HARBOR_CMD+=(--ak "version=${CODEX_VERSION}") ;;
opencode) HARBOR_CMD+=(--ak "version=${OPENCODE_VERSION}") ;;
pi) HARBOR_CMD+=(--ak "version=${PI_VERSION}") ;;
esac
}

Expand Down Expand Up @@ -401,6 +405,7 @@ if [[ "${HARBOR_MODEL_SET}" -eq 0 ]]; then
case "${AGENT}" in
claude-code|codex) HARBOR_MODEL="${MODEL}" ;;
opencode) HARBOR_MODEL="nvidia/${MODEL}" ;;
pi) HARBOR_MODEL="switchyard/${MODEL}" ;;
*) HARBOR_MODEL="openai/${MODEL}" ;;
esac
else
Expand Down Expand Up @@ -573,7 +578,12 @@ fi
add_agent_version_kwarg

if [[ -n "${REASONING_EFFORT}" ]]; then
HARBOR_CMD+=(--ak "reasoning_effort=${REASONING_EFFORT}")
if [[ "${AGENT}" == "pi" ]]; then
# Harbor's pi agent takes --thinking (off|minimal|low|medium|high|xhigh), not reasoning_effort.
HARBOR_CMD+=(--ak "thinking=${REASONING_EFFORT}")
else
HARBOR_CMD+=(--ak "reasoning_effort=${REASONING_EFFORT}")
fi
# Claude code does not support thinking=true for now, so we use adaptive instead
if [[ "${AGENT}" == "claude-code" ]] && ! harbor_extra_has_agent_kwarg thinking; then
HARBOR_CMD+=(--ak "thinking=adaptive")
Expand Down Expand Up @@ -747,7 +757,7 @@ fi

AGENT_VERSIONS_JSON="$(json_object_from_pairs \
claude_code "${CLAUDE_CODE_VERSION}" codex "${CODEX_VERSION}" \
opencode "${OPENCODE_VERSION}" node "${NODE_VERSION}")"
opencode "${OPENCODE_VERSION}" pi "${PI_VERSION}" node "${NODE_VERSION}")"
HARBOR_EXTRA_JSON="$(json_array)"
if [[ "${#HARBOR_EXTRA[@]}" -gt 0 ]]; then
HARBOR_EXTRA_JSON="$(json_array "${HARBOR_EXTRA[@]}")"
Expand Down
8 changes: 7 additions & 1 deletion crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,15 @@ are required. All configured semantic names use exact ASCII case-insensitive mat
handoff notes, per-tier system prompts, and a capability-judge fallback are documented in
[Stage-Router Routing](../../docs/routing_algorithms/stage_router_routing.md).

## Codex model discovery
## Model discovery

`GET /v1/models` returns the standard `data` list and an empty Codex `models` list.
Each entry reports the route's declared `tool_calling` and `vision` under `capabilities`.
It reports the route's declared `context_window` as the top-level `context_length` field.
OpenAI-compatible clients such as Oh My Pi read `context_length` when they build their
model list from this endpoint. See
[Use Switchyard with Oh My Pi](../../docs/integrations/oh_my_pi.md).

Codex keeps its own model catalog and instructions. Select a Switchyard route explicitly
with `codex --model route-id`; route aliases do not appear automatically in Codex's model
picker. Unknown aliases use Codex's generic defaults and do not receive Switchyard's
Expand Down
Loading
Loading