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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* **proxy:** measure and surface rolling and current token throughput metrics (active/wall-clock input, compression, effective forward, and streamed generation) in `headroom perf` CLI and the dashboard ([#959](https://github.com/chopratejas/headroom/issues/959)).
* **vibe:** add Mistral Vibe CLI support with `headroom wrap vibe`.
* **wrap:** add OpenCode (anomalyco) support with `headroom wrap opencode`. OpenCode does not honor `OPENAI_BASE_URL`/`ANTHROPIC_BASE_URL` env vars, so the wrapper injects an inline `OPENCODE_CONFIG_CONTENT` config that points both the `openai` and `anthropic` providers' `baseURL` at the local proxy (both `/v1`-based so the `@ai-sdk` clients resolve to the proxy's `/v1/chat/completions` and `/v1/messages` routes); per-project savings are attributed via the `X-Headroom-Project` header.
* **proxy:** per-project savings breakdown on the dashboard for all wrapped agents — Claude Code, Codex, aider, Copilot, and Cursor ([#802](https://github.com/chopratejas/headroom/issues/802)). `headroom wrap claude`/`codex` tag requests with an `X-Headroom-Project` header (launch-directory name); `wrap aider`/`copilot`/`cursor` — whose clients cannot send custom headers — use a `/p/<name>` base-URL prefix the proxy strips. Savings are aggregated per project (persisted, schema v3 with transparent v2 migration), exposed as `savings.per_project` in `/stats` and `projects` in `/stats-history`, and shown in a Per-Project Savings dashboard table.
* **memory:** opt-in Apple-GPU (MPS) embedding offload via `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`. When set (and Apple MPS is available), the memory embedder runs on the torch sentence-transformers backend on the Apple GPU instead of the default ONNX CPU embedder, freeing the CPU under load. If MPS or the dependencies are unavailable, Headroom logs a warning and uses the existing default embedder selection path (ONNX when available, then the pre-existing local fallback). MPS encode calls are serialized internally (torch-MPS is not thread-safe). Adds the new `[pytorch-mps]` extra (`pip install 'headroom-ai[pytorch-mps]'`). Default behavior is unchanged.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Headroom compresses everything your AI agent reads — tool outputs, logs, RAG c

- **Library** — `compress(messages)` in Python or TypeScript, inline in any app
- **Proxy** — `headroom proxy --port 8787`, zero code changes, any language
- **Agent wrap** — `headroom wrap claude|codex|cursor|aider|copilot` in one command
- **Agent wrap** — `headroom wrap claude|codex|cursor|aider|copilot|opencode` in one command
- **MCP server** — `headroom_compress`, `headroom_retrieve`, `headroom_stats` for any MCP client
- **Cross-agent memory** — shared store across Claude, Codex, Gemini, auto-dedup
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.md` / `AGENTS.md`
Expand Down Expand Up @@ -197,6 +197,7 @@ shows an **Output Tokens Saved** card next to input compression, labelled
| Aider | ✅ | starts proxy + launches |
| Copilot CLI | ✅ | starts proxy + launches |
| OpenClaw | ✅ | installs as ContextEngine plugin |
| OpenCode | ✅ | injects OPENCODE_CONFIG_CONTENT |

Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`.

Expand Down
96 changes: 91 additions & 5 deletions headroom/cli/wrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
from headroom.providers.openclaw import (
normalize_gateway_provider_ids as _normalize_openclaw_gateway_provider_ids_impl,
)
from headroom.providers.opencode import build_launch_env as _build_opencode_launch_env
from headroom.proxy.project_context import with_project_prefix as _with_project_prefix

from .main import main
Expand Down Expand Up @@ -2910,6 +2911,7 @@ def wrap() -> None:
headroom wrap goose # Goose (Block) CLI
headroom wrap openhands # OpenHands CLI
headroom wrap openclaw # OpenClaw plugin bootstrap
headroom wrap opencode # OpenCode (anomalyco) via OPENCODE_CONFIG_CONTENT

\b
`wrap` vs `proxy`:
Expand All @@ -2918,11 +2920,6 @@ def wrap() -> None:
- `headroom proxy` — just the proxy. Use this with any
OpenAI/Anthropic-compatible client by setting
ANTHROPIC_BASE_URL / OPENAI_BASE_URL yourself.

\b
Note: `headroom wrap opencode` does NOT exist. For opencode, run
`headroom proxy` and point opencode at it via OPENAI_BASE_URL.
`openclaw` is a separate tool — different from opencode.
"""


Expand Down Expand Up @@ -3899,6 +3896,95 @@ def aider(
)


# =============================================================================
# OpenCode
# =============================================================================


@wrap.command(context_settings={"ignore_unknown_options": True})
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
@click.option(
"--no-context-tool",
"--no-rtk",
"no_rtk",
is_flag=True,
help="Skip CLI context-tool setup",
)
@click.option(
"--code-graph",
is_flag=True,
help="Enable code graph indexing via codebase-memory-mcp (optional)",
)
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
@click.option("--learn", is_flag=True, help="Enable live traffic learning")
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
@click.option(
"--backend", default=None, help="API backend: 'anthropic', 'anyllm', 'litellm-vertex', etc."
)
@click.option("--anyllm-provider", default=None, help="Provider for any-llm backend")
@click.option("--region", default=None, help="Cloud region for Bedrock/Vertex")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--prepare-only", is_flag=True, hidden=True)
@click.argument("opencode_args", nargs=-1, type=click.UNPROCESSED)
def opencode(
port: int,
no_rtk: bool,
code_graph: bool,
no_proxy: bool,
learn: bool,
memory: bool,
backend: str | None,
anyllm_provider: str | None,
region: str | None,
verbose: bool,
prepare_only: bool,
opencode_args: tuple,
) -> None:
"""Launch OpenCode through Headroom proxy.

\b
OpenCode ignores OPENAI_BASE_URL / ANTHROPIC_BASE_URL, so the proxy
endpoint is injected via OPENCODE_CONFIG_CONTENT (inline JSON setting
provider.<id>.options.baseURL). Same family as aider/copilot: starts
the proxy and launches the tool.

\b
Examples:
headroom wrap opencode # Start proxy + opencode
headroom wrap opencode -- --model gpt-4o # Pass args to opencode
headroom wrap opencode --port 9999 # Custom proxy port
"""
if prepare_only:
return

opencode_bin = shutil.which("opencode")
if not opencode_bin:
click.echo("Error: 'opencode' not found in PATH.")
click.echo("Install OpenCode: https://github.com/anomalyco/opencode")
raise SystemExit(1)

env, env_vars_display = _build_opencode_launch_env(
port, os.environ, project=_project_name_from_cwd()
)

_launch_tool(
binary=opencode_bin,
args=opencode_args,
env=env,
port=port,
no_proxy=no_proxy,
tool_label="OPENCODE",
env_vars_display=env_vars_display,
learn=learn,
memory=memory,
agent_type="opencode",
code_graph=code_graph,
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
)


# =============================================================================
# Mistral Vibe
# =============================================================================
Expand Down
5 changes: 5 additions & 0 deletions headroom/providers/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""OpenCode-specific provider helpers."""

from .runtime import build_launch_env, proxy_base_url

__all__ = ["build_launch_env", "proxy_base_url"]
36 changes: 36 additions & 0 deletions headroom/providers/opencode/runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Runtime helpers for OpenCode (anomalyco/opencode) integrations."""

from __future__ import annotations

import json
import os
from collections.abc import Mapping
from typing import Any

from headroom.proxy.savings_tracker import sanitize_project_name


def proxy_base_url(port: int) -> str:
# Both @ai-sdk/openai and @ai-sdk/anthropic append the bare resource path
# ('/chat/completions', '/messages') to a baseURL that MUST end in /v1.
return f"http://127.0.0.1:{port}/v1"


def build_launch_env(
port: int,
environ: Mapping[str, str] | None = None,
project: str | None = None,
) -> tuple[dict[str, str], list[str]]:
env = dict(environ or os.environ)
base_url = proxy_base_url(port)
options: dict[str, Any] = {"baseURL": base_url}
name = sanitize_project_name(project)
if name:
options = {**options, "headers": {"X-Headroom-Project": name}}
config = {
"provider": {"openai": {"options": options}, "anthropic": {"options": options}},
"autoupdate": False,
}
content = json.dumps(config)
env["OPENCODE_CONFIG_CONTENT"] = content
return env, [f"OPENCODE_CONFIG_CONTENT={content}"]
4 changes: 3 additions & 1 deletion headroom/telemetry/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
logger = logging.getLogger(__name__)


_KNOWN_WRAP_AGENTS = frozenset({"claude", "copilot", "codex", "aider", "cursor", "openclaw"})
_KNOWN_WRAP_AGENTS = frozenset(
{"claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"}
)

# Stack slugs must start with a letter and contain only [a-z0-9_], max 64 chars.
# Applied at every ingress (env var, HTTP header, stats aggregation) so downstream
Expand Down
64 changes: 64 additions & 0 deletions tests/test_provider_opencode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

import json

from headroom.providers.opencode import build_launch_env


def test_opencode_build_launch_env_sets_config_content_without_mutating_input() -> None:
# Arrange
source_env = {"EXISTING": "value"}

# Act
env, lines = build_launch_env(8787, environ=source_env)

# Assert
assert source_env == {"EXISTING": "value"}
assert env["EXISTING"] == "value"
assert "OPENCODE_CONFIG_CONTENT" in env
assert lines == [f"OPENCODE_CONFIG_CONTENT={env['OPENCODE_CONFIG_CONTENT']}"]


def test_opencode_base_urls_end_with_v1_for_both_providers() -> None:
# Act
env, _lines = build_launch_env(8787, environ={})
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])

# Assert
openai_base = config["provider"]["openai"]["options"]["baseURL"]
anthropic_base = config["provider"]["anthropic"]["options"]["baseURL"]
assert openai_base.endswith("/v1")
assert anthropic_base.endswith("/v1")
assert openai_base == "http://127.0.0.1:8787/v1"
assert anthropic_base == "http://127.0.0.1:8787/v1"


def test_opencode_config_pins_autoupdate_false() -> None:
# Act
env, _lines = build_launch_env(8787, environ={})
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])

# Assert
assert config["autoupdate"] is False


def test_opencode_project_sets_sanitized_header_under_both_providers() -> None:
# Act
env, _lines = build_launch_env(8787, environ={}, project="My Proj")
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])

# Assert
for provider_id in ("openai", "anthropic"):
options = config["provider"][provider_id]["options"]
assert options["headers"]["X-Headroom-Project"] == "My Proj"


def test_opencode_no_project_sets_no_headers_key() -> None:
# Act
env, _lines = build_launch_env(8787, environ={}, project=None)
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])

# Assert
for provider_id in ("openai", "anthropic"):
options = config["provider"][provider_id]["options"]
assert "headers" not in options
Loading