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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)).
* **cli:** add `--rpm`/`--tpm` and `HEADROOM_RPM`/`HEADROOM_TPM` to the Click proxy command for rate-limit parity with the legacy CLI -- closes [#1350](https://github.com/headroomlabs-ai/headroom/issues/1350) (Problem 1).
* **proxy:** register `ToolResultInterceptorTransform` in explicit transforms list when `HEADROOM_INTERCEPT_ENABLED` is set — closes [#829](https://github.com/headroomlabs-ai/headroom/issues/829).
* **opencode:** write Headroom MCP config as a local stdio server instead of a remote `/mcp` URL, keep provider-only installs from adding MCP config, and allow `install apply --target opencode` ([#1380](https://github.com/headroomlabs-ai/headroom/issues/1380)).
* **code:** keep Python `from __future__` imports before executable code during AST compression and validate compressed Python with `compile(..., "exec")` so compile-time syntax rules are enforced ([#1233](https://github.com/chopratejas/headroom/issues/1233)).
* **proxy:** report real input tokens on the streaming `message_start` event for LiteLLM/Bedrock-backed requests. LiteLLM streaming never surfaces prompt tokens mid-stream, so `message_start.usage.input_tokens` was always `0`; Anthropic clients (e.g. Claude Code) read input-token metrics from that event, underreporting token usage by ~99% in OTel/CloudWatch dashboards. The Bedrock streamer now backfills `input_tokens` with the count Headroom actually sent upstream when the backend leaves it unset, preserving any non-zero value the backend genuinely reports ([#1132](https://github.com/chopratejas/headroom/issues/1132)).
* **proxy:** give buffered Anthropic request paths their own longer read timeout, so long `/v1/messages` turns and Anthropic batch or passthrough reads no longer trip the generic proxy cap while unrelated request timeouts stay unchanged.
Expand Down
29 changes: 13 additions & 16 deletions docs/content/docs/opencode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,20 @@ headroom unwrap opencode
| Step | What happens |
|---|---|
| Proxy | Starts the Headroom proxy unless `--no-proxy` is set |
| Provider config | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json` and `OPENCODE_CONFIG_CONTENT` |
| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` so OpenCode reads provider, model, and MCP config at launch |
| Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so `/connect` providers keep their own routing |
| Context tool | Injects RTK or `lean-ctx` instructions into OpenCode AGENTS files |
| MCP setup | Registers Headroom MCP tools: `headroom_compress`, `headroom_retrieve`, and `headroom_stats` |
| Provider config | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json` and `OPENCODE_CONFIG_CONTENT`, pointing at `http://127.0.0.1:<port>/v1` |
| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` so OpenCode reads provider, model, plugin, and local MCP config at launch |
| Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so OpenCode `/connect` providers keep their own routing |
| Context tool | Injects RTK or `lean-ctx` instructions into `~/.config/opencode/AGENTS.md` and project `AGENTS.md` |
| MCP setup | Registers Headroom MCP tools through a local stdio server: `headroom_compress`, `headroom_retrieve`, and `headroom_stats` |
| Serena MCP | Optionally registers Serena code graph tools. Use `--no-serena` to skip it |
| Backup | Snapshots `opencode.json` to `opencode.json.headroom-backup` before changing it |
| Launch | Starts the `opencode` binary with the generated config |

## Options

```bash
headroom wrap opencode --port 8787 \ # Proxy port. Defaults to a random available port
headroom wrap opencode \
--port 8787 \ # Proxy port. Defaults to a random available port
--no-rtk \ # Skip RTK context tool injection
--no-mcp \ # Skip Headroom MCP registration
--no-serena \ # Skip Serena code graph MCP
Expand Down Expand Up @@ -68,7 +69,7 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json`

| Variable | Description |
|---|---|
| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, model, and MCP config injected by `wrap` |
| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, model, plugin, and optional local MCP config injected by `wrap` |
| `HEADROOM_PROXY_URL` | Proxy URL passed to the native `headroom-opencode` plugin. Defaults to `http://127.0.0.1:8787` inside the plugin |
| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK |

Expand All @@ -87,16 +88,12 @@ See [Failure Learning](/docs/failure-learning) for details on the learn system.
`headroom install` supports OpenCode as a target for persistent provider wiring:

```bash
headroom install apply --preset persistent-service --providers manual --target opencode
headroom install apply --preset persistent-service --scope provider --providers manual --target opencode
```

This writes the Headroom provider into `~/.config/opencode/opencode.json` and keeps the proxy running on port 8787.

Provider scope is also supported:

```bash
headroom install apply --preset persistent-service --scope provider --providers manual --target opencode
```
Provider-only installs do not write Headroom MCP config. MCP tools are added by `headroom wrap opencode` unless `--no-mcp` is set.

## Native OpenCode Plugin

Expand All @@ -114,7 +111,7 @@ export default async function plugin(input) {
}
```

Use this plugin when OpenCode should intercept provider traffic in-process. Use `headroom wrap opencode` when you want the CLI to manage the proxy, config injection, MCP registration, backups, and unwrap behavior.
Use this plugin when OpenCode should intercept provider traffic in-process. Use `headroom wrap opencode` when you want the CLI to manage the proxy, config injection, local MCP registration, backups, and unwrap behavior.

## Programmatic Config Helpers

Expand All @@ -140,8 +137,8 @@ const retrieve = createHeadroomRetrieveTool({
## How It Works Under The Hood

1. **Config injection**. The wrapper writes a `provider.headroom` block into `opencode.json`. The provider uses `@ai-sdk/openai-compatible`, which OpenCode supports natively. Model mappings route requests through `http://127.0.0.1:<port>/v1`.
2. **Runtime config**. `OPENCODE_CONFIG_CONTENT` is set as an env var containing the full provider, model, and MCP JSON. OpenCode reads it at startup and merges it with on-disk config.
3. **MCP tools**. Headroom registers `headroom_compress`, `headroom_retrieve`, and `headroom_stats` unless `--no-mcp` is set.
2. **Runtime config**. `OPENCODE_CONFIG_CONTENT` is set as an env var containing the full provider, model, plugin, and local MCP JSON. OpenCode reads it at startup and merges it with on-disk config.
3. **MCP tools**. Headroom registers `headroom_compress`, `headroom_retrieve`, and `headroom_stats` through a local stdio MCP server unless `--no-mcp` is set.
4. **Native plugin path**. `HeadroomPlugin` installs Headroom transport interception and uses `HEADROOM_PROXY_URL` or `http://127.0.0.1:8787` to reach the proxy.
5. **Unwrap**. `headroom unwrap opencode` restores `opencode.json` from the pre-wrap backup when present, strips Headroom marker blocks when no backup exists, and unregisters Headroom MCP servers.

Expand Down
2 changes: 1 addition & 1 deletion headroom/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None:
"--target",
"targets",
multiple=True,
type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]),
type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"]),
help="Tool target to configure when --providers manual is used.",
)
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
Expand Down
12 changes: 4 additions & 8 deletions headroom/mcp_registry/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def _entry_to_spec(name: str, entry: dict[str, Any]) -> ServerSpec:
else:
command = str(command_value) if command_value else ""
args = ()
env_value = entry.get("env", {})
env_value = entry.get("environment", entry.get("env", {}))
env: dict[str, str] = {}
if isinstance(env_value, dict):
env = {str(k): str(v) for k, v in env_value.items()}
Expand All @@ -72,16 +72,12 @@ def _entry_to_spec(name: str, entry: dict[str, Any]) -> ServerSpec:

def _spec_to_entry(spec: ServerSpec) -> dict[str, Any]:
entry: dict[str, Any] = {
"type": "remote",
"url": "",
"type": "local",
"command": [spec.command, *spec.args],
"enabled": True,
}
if spec.args:
entry["command"] = [spec.command, *spec.args]
else:
entry["command"] = spec.command
if spec.env:
entry["env"] = dict(spec.env)
entry["environment"] = dict(spec.env)
return entry


Expand Down
27 changes: 11 additions & 16 deletions headroom/providers/opencode/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import click

from headroom.install.paths import opencode_config_path
from headroom.mcp_registry.install import DEFAULT_PROXY_URL

# Headroom-managed JSON marker comments for idempotent block injection.
_PROVIDER_MARKER_START = "// --- Headroom proxy provider ---"
Expand Down Expand Up @@ -98,12 +99,16 @@ def _render_provider_block(port: int) -> str:

def _render_mcp_block(port: int) -> str:
"""Render a Headroom MCP block as a JSON comment-wrapped snippet."""
proxy_url = f"http://127.0.0.1:{port}"
mcp_entry: dict[str, Any] = {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
}
if proxy_url != DEFAULT_PROXY_URL:
mcp_entry["environment"] = {"HEADROOM_PROXY_URL": proxy_url}
mcp = {
"headroom": {
"type": "remote",
"url": f"http://127.0.0.1:{port}/mcp",
"enabled": True,
}
"headroom": mcp_entry,
}
lines = [
_MCP_MARKER_START,
Expand Down Expand Up @@ -192,7 +197,7 @@ def inject_opencode_provider_config(port: int) -> None:
data = {}

# Strip any prior Headroom-managed blocks before re-injecting.
if _PROVIDER_MARKER_START in content:
if _PROVIDER_MARKER_START in content or _MCP_MARKER_START in content:
content = strip_opencode_headroom_blocks(content)
data = _parse_json_loose(content)

Expand All @@ -206,16 +211,6 @@ def inject_opencode_provider_config(port: int) -> None:
}
data = _inject_key_into_json(data, "provider", provider)

# Inject MCP if not already present.
mcp = {
"headroom": {
"type": "remote",
"url": f"http://127.0.0.1:{port}/mcp",
"enabled": True,
}
}
data = _inject_key_into_json(data, "mcp", mcp)

# Write back as formatted JSON (opencode uses standard JSON with comments).
output = json.dumps(data, indent=2) + "\n"
config_file.write_text(output, encoding="utf-8")
Expand Down
16 changes: 11 additions & 5 deletions headroom/providers/opencode/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import os
from collections.abc import Mapping

from headroom.mcp_registry.install import DEFAULT_PROXY_URL

from .config import HEADROOM_OPENCODE_PLUGIN


Expand Down Expand Up @@ -37,12 +39,16 @@ def build_opencode_config_content(
}
}
if include_mcp:
proxy_url = f"http://127.0.0.1:{port}"
mcp_entry: dict[str, object] = {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
}
if proxy_url != DEFAULT_PROXY_URL:
mcp_entry["environment"] = {"HEADROOM_PROXY_URL": proxy_url}
config["mcp"] = {
"headroom": {
"type": "remote",
"url": f"http://127.0.0.1:{port}/mcp",
"enabled": True,
}
"headroom": mcp_entry,
}
if include_plugin:
config["plugin"] = [[HEADROOM_OPENCODE_PLUGIN, {"proxyUrl": base_url}]]
Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli/test_install_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,57 @@ def test_install_apply_rejects_provider_scope_targets_without_support() -> None:
assert "Provider scope supports only claude, codex, openclaw, and opencode" in result.output


def test_install_apply_accepts_opencode_target(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, object] = {}

class Manifest:
profile = "default"
preset = "persistent-service"
runtime_kind = "python"
supervisor_kind = "service"
scope = "provider"
health_url = "http://127.0.0.1:8787/readyz"
targets = ["opencode"]
mutations = []
artifacts = []

manifest = Manifest()

def fake_build_manifest(**kwargs):
captured.update(kwargs)
return manifest

monkeypatch.setattr("headroom.cli.install.build_manifest", fake_build_manifest)
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None)
monkeypatch.setattr("headroom.cli.install.start_supervisor", lambda deployment: None)
monkeypatch.setattr("headroom.cli.install.start_detached_agent", lambda profile: None)
monkeypatch.setattr(
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
)

result = runner.invoke(
main,
[
"install",
"apply",
"--scope",
"provider",
"--providers",
"manual",
"--target",
"opencode",
],
)

assert result.exit_code == 0, result.output
assert captured["targets"] == ["opencode"]
assert "Targets: opencode" in result.output


def test_install_apply_restores_previous_deployment_after_failed_update(monkeypatch) -> None:
runner = CliRunner()
calls: list[str] = []
Expand Down
10 changes: 9 additions & 1 deletion tests/test_cli/test_wrap_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ def fake_launch_tool(**kwargs): # noqa: ANN003
env = captured["env"]
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
assert "mcp" not in config
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
persisted_config = json.loads(config_file.read_text())
assert "headroom" not in persisted_config.get("mcp", {})


def test_wrap_opencode_injects_mcp_by_default(
Expand All @@ -186,7 +189,12 @@ def fake_launch_tool(**kwargs): # noqa: ANN003
env = captured["env"]
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
assert "mcp" in config
assert config["mcp"]["headroom"]["type"] == "remote"
assert config["mcp"]["headroom"] == {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
"environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9000"},
}


def test_wrap_opencode_injects_rtk_into_agents_md(
Expand Down
42 changes: 37 additions & 5 deletions tests/test_mcp_registry_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ def test_get_server_returns_spec_when_present(tmp_path: Path) -> None:
config = {
"mcp": {
"headroom": {
"type": "remote",
"url": "http://127.0.0.1:8787/mcp",
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
}
}
Expand All @@ -74,6 +74,8 @@ def test_get_server_returns_spec_when_present(tmp_path: Path) -> None:
spec = registrar.get_server("headroom")
assert spec is not None
assert spec.name == "headroom"
assert spec.command == "headroom"
assert spec.args == ("mcp", "serve")


def test_register_server_creates_config_when_missing(tmp_path: Path) -> None:
Expand All @@ -86,6 +88,12 @@ def test_register_server_creates_config_when_missing(tmp_path: Path) -> None:
assert result.status == RegisterStatus.REGISTERED
config_path = tmp_path / "opencode.json"
assert config_path.exists()
data = json.loads(config_path.read_text())
assert data["mcp"]["headroom"] == {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
}


def test_register_server_idempotent(tmp_path: Path) -> None:
Expand Down Expand Up @@ -259,7 +267,7 @@ def test_register_server_with_env_vars(tmp_path: Path) -> None:
registrar.register_server(spec)

data = json.loads((tmp_path / "opencode.json").read_text())
assert data["mcp"]["headroom"]["env"] == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}
assert data["mcp"]["headroom"]["environment"] == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}


def test_register_then_re_register_with_different_env_returns_mismatch(tmp_path: Path) -> None:
Expand Down Expand Up @@ -306,6 +314,30 @@ def test_entry_to_spec_command_as_string() -> None:
assert spec.args == ()


def test_entry_to_spec_reads_opencode_environment() -> None:
"""_entry_to_spec reads OpenCode's environment map."""
entry = {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
"environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"},
}
spec = _entry_to_spec("headroom", entry)
assert spec.env == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}


def test_entry_to_spec_reads_legacy_env() -> None:
"""_entry_to_spec keeps compatibility with previously written env maps."""
entry = {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": True,
"env": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"},
}
spec = _entry_to_spec("headroom", entry)
assert spec.env == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}


def test_entry_to_spec_no_command() -> None:
"""_entry_to_spec handles an entry without a 'command' key."""
entry: dict[str, Any] = {
Expand All @@ -329,9 +361,9 @@ def test_spec_to_entry_roundtrip() -> None:
env={"KEY": "VAL"},
)
entry = _spec_to_entry(original)
assert entry["type"] == "remote"
assert entry["type"] == "local"
assert entry["command"] == ["python", "-m", "server"]
assert entry["env"] == {"KEY": "VAL"}
assert entry["environment"] == {"KEY": "VAL"}

restored = _entry_to_spec("test", entry)
assert restored.name == original.name
Expand Down
Loading
Loading