diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3a33dca..132e9f21b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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. diff --git a/README.md b/README.md index 95a52ee32..7891fc1a1 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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`. diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 86d2373c6..b4f776c9e 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -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 @@ -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`: @@ -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. """ @@ -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..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 # ============================================================================= diff --git a/headroom/providers/opencode/__init__.py b/headroom/providers/opencode/__init__.py new file mode 100644 index 000000000..98a00a94f --- /dev/null +++ b/headroom/providers/opencode/__init__.py @@ -0,0 +1,5 @@ +"""OpenCode-specific provider helpers.""" + +from .runtime import build_launch_env, proxy_base_url + +__all__ = ["build_launch_env", "proxy_base_url"] diff --git a/headroom/providers/opencode/runtime.py b/headroom/providers/opencode/runtime.py new file mode 100644 index 000000000..ba1b69ba2 --- /dev/null +++ b/headroom/providers/opencode/runtime.py @@ -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}"] diff --git a/headroom/telemetry/context.py b/headroom/telemetry/context.py index f3494a2b8..3a486d3f1 100644 --- a/headroom/telemetry/context.py +++ b/headroom/telemetry/context.py @@ -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 diff --git a/tests/test_provider_opencode.py b/tests/test_provider_opencode.py new file mode 100644 index 000000000..9d89b7fd2 --- /dev/null +++ b/tests/test_provider_opencode.py @@ -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