mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(opencode): route native providers + load transport plugin, fix Serena context (#1573)
## Description `headroom wrap opencode` looked like it worked (proxy started, opencode launched) but **no inference reached the proxy**, so users saw zero savings (#1572). Root causes: 1. The injected synthetic `headroom` provider (`@ai-sdk/openai-compatible`) had **no `models` and no `apiKey`** → opencode raised `ProviderModelNotFoundError`, and it only ever targets OpenAI. 2. The wrap injected a reference to the **unpublished `headroom-opencode` npm plugin**, which opencode silently failed to resolve → the transparent transport never loaded. 3. Serena was launched with `--context opencode`, a context Serena does not ship → crash on launch (#1549). This PR makes `headroom wrap opencode` route opencode's traffic through the proxy with the user's **own API key** (no key written to disk), and gets the transparent transport plugin actually loading. Closes #1572 Closes #1549 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - **`runtime.py`** — two complementary routing layers (both verified against opencode 1.17): 1. Override opencode's native `anthropic`/`openai` provider `baseURL` to the proxy. Reliable, credential-independent (covers API key **and** subscription), keeps native model metadata/limits, reuses the user's existing key. This is the always-on layer and the only one a pip-only install needs. 2. Load the transport plugin **by absolute path** when it has been built (`headroom_opencode_plugin_path()`), self-configured via `HEADROOM_PROXY_URL`. Covers providers we don't name (Gemini, Copilot, custom gateways) and providers added mid-session. Loopback URLs aren't double-routed, so the two layers coexist. - **`wrap.py`** — Serena context `opencode` → `agent` (valid context). - **`plugins/opencode/`** — new `src/entry.opencode.ts` loader entry that exports **only** the plugin function (opencode rejects a module with non-function exports: "Plugin export is not a function"); tsup builds it as a second entry. - **tests** — updated `test_providers_opencode_config.py` for path-based plugin injection + a skip-when-unbuilt case. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_providers_opencode_config.py tests/test_cli/test_wrap_opencode.py -q 72 passed in 0.59s $ (cd plugins/opencode && npm test) Test Files 2 passed (2) Tests 9 passed (9) $ ruff check headroom/providers/opencode/runtime.py headroom/cli/wrap.py tests/test_providers_opencode_config.py ✓ Ruff: No issues found $ mypy headroom/providers/opencode/runtime.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - **Environment:** macOS, opencode 1.17.11 (npm), headroom proxy 0.28.0 (local), Anthropic API key from `.env`. - **Exact command:** ``` headroom wrap opencode --no-serena --no-context-tool --no-proxy --port 8787 \ -- run -m anthropic/claude-haiku-4-5-20251001 "Reply with exactly: WRAPWORKS" ``` - **Observed result:** opencode printed `plugin=headroom-opencode` (loaded, no error) and returned `WRAPWORKS`. The proxy log shows the request routed through it: ``` event=outbound_request method=POST path=https://api.anthropic.com/v1/messages source=passthrough event=proxy_inbound_response path=/v1/messages status=200 PERF model=claude-haiku-4-5-20251001 cache_hit_pct=97 client=opencode ``` Compression verified on a large tool_result (`client=opencode`): ``` Pipeline complete: 170653 -> 77 tokens (saved 170576, 100.0% reduction) PERF tok_before=151309 tok_after=67 tok_saved=151242 transforms=router:tool_result:log client=opencode ``` - **Not tested:** custom OpenAI-compatible gateways (need the proxy to honor `x-headroom-base-url` in the dedicated OpenAI handler — open PR #1502); interactive TUI (verified the headless `opencode run` path). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes - **Plugin shipping:** the plugin loads by repo-relative path, which works for source/editable installs. `plugins/opencode/dist/` is gitignored, so the plugin must be built (`cd plugins/opencode && npm install && npm run build`) for layer 2 to activate; pip-only installs gracefully fall back to layer 1 (native baseURL override). Bundling `dist/` into the package or publishing `headroom-opencode` to npm is a follow-up for universal shipping. - **CHANGELOG:** N/A — handled by Release Please from the conventional commit. - Custom-gateway support depends on existing PR #1502 (honor `x-headroom-base-url` in the dedicated OpenAI handlers); not duplicated here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
aea3c35177
commit
ad0034f981
6 changed files with 3164 additions and 14 deletions
|
|
@ -5394,7 +5394,10 @@ def opencode(
|
|||
if not no_serena:
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
_setup_serena_mcp(OpencodeRegistrar(), context="opencode", verbose=verbose, force=True)
|
||||
# Serena ships no "opencode" context (only agent/codex/claude-code/ide/…);
|
||||
# passing --context opencode crashes Serena on launch (#1549/#1572). Use
|
||||
# the generic "agent" context, which OpenCode is.
|
||||
_setup_serena_mcp(OpencodeRegistrar(), context="agent", verbose=verbose, force=True)
|
||||
else:
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.mcp_registry.install import DEFAULT_PROXY_URL
|
||||
|
||||
|
|
@ -16,6 +17,29 @@ def proxy_base_url(port: int) -> str:
|
|||
return f"http://127.0.0.1:{port}/v1"
|
||||
|
||||
|
||||
def headroom_opencode_plugin_path() -> str | None:
|
||||
"""Return the absolute path to the built OpenCode transport plugin, or None.
|
||||
|
||||
OpenCode loads a plugin from an absolute file path (verified against
|
||||
opencode 1.17). The plugin's loader entry exports ONLY the plugin function
|
||||
(``plugins/opencode/dist/entry.opencode.js``) — the library barrel cannot
|
||||
be loaded directly ("Plugin export is not a function"). Returns ``None``
|
||||
when the plugin has not been built (e.g. a pip-only install that does not
|
||||
ship ``plugins/``), in which case wrap falls back to the native-provider
|
||||
baseURL override, which already covers Anthropic/OpenAI.
|
||||
|
||||
``HEADROOM_OPENCODE_PLUGIN_PATH`` overrides the resolved path.
|
||||
"""
|
||||
override = os.environ.get("HEADROOM_OPENCODE_PLUGIN_PATH", "").strip()
|
||||
if override:
|
||||
return override if Path(override).is_file() else None
|
||||
# runtime.py → opencode → providers → headroom → <repo root>
|
||||
candidate = (
|
||||
Path(__file__).resolve().parents[3] / "plugins" / "opencode" / "dist" / "entry.opencode.js"
|
||||
)
|
||||
return str(candidate) if candidate.is_file() else None
|
||||
|
||||
|
||||
def build_opencode_config_content(
|
||||
*,
|
||||
port: int,
|
||||
|
|
@ -24,18 +48,37 @@ def build_opencode_config_content(
|
|||
) -> dict[str, object]:
|
||||
"""Build JSON payload for ``OPENCODE_CONFIG_CONTENT``.
|
||||
|
||||
Runtime wrap injects the Headroom provider as a stable explicit fallback,
|
||||
plus the Headroom plugin which transparently routes provider fetch traffic
|
||||
through the local proxy without rewriting user provider config URLs.
|
||||
Two complementary routing layers (both verified against opencode 1.17):
|
||||
|
||||
1. **Native-provider baseURL override** — points OpenCode's built-in
|
||||
``anthropic`` / ``openai`` providers at the proxy. Keeps native provider
|
||||
identity (model metadata, output-token limits) and reuses the user's own
|
||||
API keys (env / ``opencode auth``); the proxy forwards upstream by path
|
||||
(``/v1/messages`` → Anthropic, ``/v1/chat/completions`` → OpenAI). This
|
||||
is the reliable always-on layer and the only one shipped pip-only
|
||||
installs need.
|
||||
|
||||
2. **Transparent transport plugin** — when the local plugin is built, it is
|
||||
loaded by absolute path and patches ``fetch``/``http`` to reroute *every*
|
||||
provider's traffic through the proxy, tagging the real upstream via
|
||||
``x-headroom-base-url``. This covers providers we don't name (Gemini,
|
||||
Copilot, custom gateways) and providers added mid-session. The plugin
|
||||
self-configures from ``HEADROOM_PROXY_URL`` (set in :func:`build_launch_env`).
|
||||
Loopback URLs are not double-routed, so it coexists with layer 1.
|
||||
|
||||
ponytail: config-level ``options.baseURL`` is reliable where the env-var
|
||||
override (``ANTHROPIC_BASE_URL``) is not — verified against opencode 1.17.
|
||||
"""
|
||||
base_url = proxy_base_url(port)
|
||||
config: dict[str, object] = {
|
||||
"provider": {
|
||||
"anthropic": {"options": {"baseURL": base_url}},
|
||||
"openai": {"options": {"baseURL": base_url}},
|
||||
"headroom": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Headroom Proxy",
|
||||
"options": {"baseURL": base_url},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if include_mcp:
|
||||
|
|
@ -51,7 +94,11 @@ def build_opencode_config_content(
|
|||
"headroom": mcp_entry,
|
||||
}
|
||||
if include_plugin:
|
||||
config["plugin"] = [[HEADROOM_OPENCODE_PLUGIN, {"proxyUrl": base_url}]]
|
||||
plugin_path = headroom_opencode_plugin_path()
|
||||
if plugin_path:
|
||||
# Plain absolute-path string; the plugin reads HEADROOM_PROXY_URL
|
||||
# from the launch env (build_launch_env sets it).
|
||||
config["plugin"] = [plugin_path]
|
||||
return config
|
||||
|
||||
|
||||
|
|
@ -66,7 +113,9 @@ def build_launch_env(
|
|||
"""Build environment variables for launching OpenCode through Headroom.
|
||||
|
||||
``OPENCODE_CONFIG_CONTENT`` carries Headroom provider/MCP/plugin config.
|
||||
Existing provider/base URL environment variables are preserved.
|
||||
Existing provider/base URL environment variables are preserved. When the
|
||||
transport plugin is loaded, ``HEADROOM_PROXY_URL`` tells it which proxy to
|
||||
route to.
|
||||
"""
|
||||
env = dict(environ or os.environ)
|
||||
|
||||
|
|
@ -78,7 +127,8 @@ def build_launch_env(
|
|||
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config_content, separators=(",", ":"))
|
||||
|
||||
display = ["OPENCODE_CONFIG_CONTENT={provider: headroom}"]
|
||||
if include_plugin:
|
||||
if "plugin" in config_content:
|
||||
env["HEADROOM_PROXY_URL"] = f"http://127.0.0.1:{port}"
|
||||
display.append(f"plugin={HEADROOM_OPENCODE_PLUGIN}")
|
||||
|
||||
if project and "HEADROOM_PROJECT" not in env:
|
||||
|
|
|
|||
3061
plugins/opencode/package-lock.json
generated
Normal file
3061
plugins/opencode/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
7
plugins/opencode/src/entry.opencode.ts
Normal file
7
plugins/opencode/src/entry.opencode.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Dedicated entry for OpenCode's plugin loader.
|
||||
//
|
||||
// OpenCode loads a plugin module and treats its exports as plugin factories —
|
||||
// it rejects the module if a non-function export is present ("Plugin export is
|
||||
// not a function"). The library barrel (index.ts) re-exports helpers/constants,
|
||||
// so it cannot be loaded directly. This entry exports ONLY the plugin function.
|
||||
export { HeadroomPlugin as default } from "./plugin.js";
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
entry: { index: "src/index.ts", "entry.opencode": "src/entry.opencode.ts" },
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
|
|
|
|||
|
|
@ -403,14 +403,37 @@ def test_inject_provider_config_no_crash_on_unwriteable_dir(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_opencode_config_content_without_mcp() -> None:
|
||||
def test_build_opencode_config_content_without_mcp(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
from headroom.providers.opencode.runtime import build_opencode_config_content
|
||||
|
||||
plugin = tmp_path / "entry.opencode.js"
|
||||
plugin.write_text("export default () => {}", encoding="utf-8")
|
||||
monkeypatch.setenv("HEADROOM_OPENCODE_PLUGIN_PATH", str(plugin))
|
||||
|
||||
config = build_opencode_config_content(port=8787, include_mcp=False)
|
||||
assert "provider" in config
|
||||
assert "mcp" not in config
|
||||
assert "model" not in config
|
||||
assert config["plugin"] == [["headroom-opencode", {"proxyUrl": "http://127.0.0.1:8787/v1"}]]
|
||||
# Native providers are pointed at the proxy so traffic routes through Headroom.
|
||||
providers = config["provider"]
|
||||
assert providers["anthropic"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
assert providers["openai"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
# The transport plugin is injected by absolute path (opencode loads it directly).
|
||||
assert config["plugin"] == [str(plugin)]
|
||||
|
||||
|
||||
def test_build_opencode_config_content_skips_plugin_when_unbuilt(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
from headroom.providers.opencode.runtime import build_opencode_config_content
|
||||
|
||||
# An override pointing at a missing file resolves to None → no plugin entry,
|
||||
# but native-provider routing still applies (the pip-only fallback).
|
||||
monkeypatch.setenv("HEADROOM_OPENCODE_PLUGIN_PATH", str(tmp_path / "missing.js"))
|
||||
config = build_opencode_config_content(port=8787)
|
||||
assert "plugin" not in config
|
||||
assert config["provider"]["anthropic"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
|
||||
|
||||
def test_build_opencode_config_content_with_mcp_uses_local_stdio() -> None:
|
||||
|
|
@ -425,12 +448,15 @@ def test_build_opencode_config_content_with_mcp_uses_local_stdio() -> None:
|
|||
}
|
||||
|
||||
|
||||
def test_build_launch_env_with_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_build_launch_env_with_project(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from headroom.providers.opencode.runtime import build_launch_env
|
||||
|
||||
monkeypatch.delenv("HEADROOM_PROJECT", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
plugin = tmp_path / "entry.opencode.js"
|
||||
plugin.write_text("export default () => {}", encoding="utf-8")
|
||||
monkeypatch.setenv("HEADROOM_OPENCODE_PLUGIN_PATH", str(plugin))
|
||||
|
||||
env, display = build_launch_env(
|
||||
port=8787,
|
||||
|
|
@ -438,7 +464,10 @@ def test_build_launch_env_with_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
include_mcp=False,
|
||||
)
|
||||
assert env["HEADROOM_PROJECT"] == "test-proj"
|
||||
assert "headroom-opencode" in env["OPENCODE_CONFIG_CONTENT"]
|
||||
# Plugin loaded → its proxy target is exported for self-configuration.
|
||||
assert env["HEADROOM_PROXY_URL"] == "http://127.0.0.1:8787"
|
||||
assert str(plugin) in env["OPENCODE_CONFIG_CONTENT"]
|
||||
assert f"plugin={HEADROOM_OPENCODE_PLUGIN}" in display
|
||||
assert "OPENAI_BASE_URL" not in env
|
||||
assert "ANTHROPIC_BASE_URL" not in env
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue