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:
Tejas Chopra 2026-06-29 15:04:56 -07:00 committed by GitHub
parent aea3c35177
commit ad0034f981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 3164 additions and 14 deletions

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View 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";

View file

@ -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,

View file

@ -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