feat(opencode): ship the transport plugin in pip installs (#2601)

## Description

The OpenCode transport plugin - the piece that gives `wrap opencode`
all-provider routing by tagging each request with `x-headroom-base-url`
- only exists in repo checkouts today. `headroom_opencode_plugin_path()`
resolves `plugins/opencode/dist/entry.opencode.js`, which pip wheels do
not ship, so every pip install silently degrades to the two-provider
(anthropic/openai) baseURL fallback. The function's own docstring
documents the gap ("a pip-only install that does not ship `plugins/`").

Shipping the existing build output is not enough: the regular tsup build
leaves `headroom-ai` and `@opencode-ai/plugin` as bare external imports,
which only resolve next to the checkout's `node_modules`. Copied into
site-packages, the file fails to load. This PR ships a self-contained
bundle inside the wheel instead.

Closes #

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `plugins/opencode/tsup.standalone.config.ts` + `npm run
build:standalone`: a second build of the loader entry with `noExternal:
[/.*/]` and `splitting: false` - a single self-contained file whose only
imports are node builtins.
- `headroom/providers/opencode/_dist/entry.opencode.js`: the committed
standalone bundle (452 KB). It sits inside the package directory, so
maturin's `python-source = "."` packaging picks it up into the wheel
with no build-system changes.
- `headroom_opencode_plugin_path()`: falls back to the packaged bundle.
Precedence otherwise unchanged: `HEADROOM_OPENCODE_PLUGIN_PATH` env
override, then a repo-checkout build (fresher during development), then
the packaged bundle.
- CI (`opencode-plugin.yml`): rebuilds the standalone bundle and fails
the run if the committed artifact drifted from source, with a one-line
fix instruction; workflow path triggers extended to
`headroom/providers/opencode/_dist/**`.
- `tests/test_providers_opencode_plugin_path.py`: packaged bundle exists
and is self-contained (no bare npm imports), env override wins, fallback
resolution order.

## 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
$ uv run --frozen --extra dev pytest tests/test_providers_opencode_plugin_path.py \
    tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py
============================== 49 passed in 0.31s ==============================

$ uvx ruff check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py
All checks passed!
$ uvx ruff format --check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py
2 files already formatted
$ uv run --frozen --extra dev mypy headroom/providers/opencode/runtime.py
Success: no issues found in 1 source file

$ cd plugins/opencode && npm run build:standalone
ESM dist-standalone/entry.opencode.js 452.28 KB
ESM Build success in 28ms
```

## Real Behavior Proof

- Environment: macOS 15 (arm64), opencode 1.18.5 (Homebrew), node 22 /
npm 10, isolated `XDG_*` dirs so no real user config was touched.
- Exact command / steps:
  1. `npm run build:standalone` in `plugins/opencode`.
2. Started a local header-logging HTTP listener on `127.0.0.1:9977`
(stands in for the proxy; logs method, path, headers, returns 401).
3. Registered the standalone bundle by absolute path in a scratch
`opencode.json` (`"plugin":
["<abs>/dist-standalone/entry.opencode.js"]`) with a `google` provider
entry and a fake API key. Note: the bundle's directory has **no**
`node_modules` - this is exactly the site-packages situation.
4. `HEADROOM_PROXY_URL=http://127.0.0.1:9977 opencode run -m
google/gemini-2.5-flash "say hi"`.
- Observed result: the listener received `POST
/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse` with
`User-Agent: opencode/1.18.5 ...` - i.e. the plugin loaded standalone
and rerouted a provider that the baseURL fallback cannot cover (native
Gemini wire format) to the proxy URL from `HEADROOM_PROXY_URL`.
- Not tested: Windows path resolution (pure `pathlib`, no platform
branches); wheel-build byte-determinism of the tsup output across OSes
(the CI drift check will surface it on the first divergent build).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable - CLI/packaging change.

## Additional Notes

- Documentation checklist item: unchecked because the only doc surface I
found is the `headroom_opencode_plugin_path()` docstring, which this PR
rewrites to describe the three-step resolution order. Happy to add a
line to `docs/content/docs/` if there is a preferred page.
- A committed build artifact is not free: the CI drift check keeps it
honest, and the byte-compare relies on tsup/esbuild determinism under
`npm ci` (pinned lockfile). If you'd rather avoid the committed artifact
entirely, the alternative is publishing `headroom-opencode` to npm (its
`package.json` is publish-ready) and registering the plugin by package
name - happy to rework in that direction; the wheel-bundled path has the
advantage of version-locking the plugin to the backend it ships with.
- Downstream motivation: Headroom Desktop manages a long-lived shared
proxy (no `wrap` launcher) and wants to register this plugin from the
installed wheel path so OpenCode users get all-provider routing there
too.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gglucass 2026-07-27 15:40:43 +02:00 committed by GitHub
parent f74d874777
commit f54f04f5bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 12999 additions and 7 deletions

View file

@ -10,11 +10,13 @@ on:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- ".github/workflows/opencode-plugin.yml"
push:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- ".github/workflows/opencode-plugin.yml"
permissions:
@ -39,3 +41,13 @@ jobs:
- run: npm run typecheck
- run: npm run build
- run: npm test
# The wheel ships a committed self-contained bundle at
# headroom/providers/opencode/_dist/entry.opencode.js so pip installs
# get the transport plugin too. Rebuild it and fail if the committed
# artifact has drifted from the source.
- run: npm run build:standalone
- name: verify committed wheel bundle matches source
run: |
cmp dist-standalone/entry.opencode.js \
../../headroom/providers/opencode/_dist/entry.opencode.js \
|| { echo "::error::headroom/providers/opencode/_dist/entry.opencode.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }

File diff suppressed because it is too large Load diff

View file

@ -23,21 +23,34 @@ def headroom_opencode_plugin_path() -> str | 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.
be loaded directly ("Plugin export is not a function").
``HEADROOM_OPENCODE_PLUGIN_PATH`` overrides the resolved path.
Resolution order:
1. ``HEADROOM_OPENCODE_PLUGIN_PATH`` env override.
2. A repo-checkout build (``plugins/opencode/dist/entry.opencode.js``)
external-deps build, resolvable because the checkout has node_modules.
3. The self-contained bundle shipped inside the wheel
(``headroom/providers/opencode/_dist/entry.opencode.js``, built by
``npm run build:standalone``) every dependency inlined, so it loads
from site-packages where no node_modules exists (verified against
opencode 1.18.5).
Returns ``None`` only when none of the three exist, in which case wrap
falls back to the native-provider baseURL override, which already covers
Anthropic/OpenAI.
"""
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 = (
repo_candidate = (
Path(__file__).resolve().parents[3] / "plugins" / "opencode" / "dist" / "entry.opencode.js"
)
return str(candidate) if candidate.is_file() else None
if repo_candidate.is_file():
return str(repo_candidate)
packaged = Path(__file__).resolve().parent / "_dist" / "entry.opencode.js"
return str(packaged) if packaged.is_file() else None
def build_opencode_config_content(

View file

@ -1,3 +1,4 @@
node_modules
dist
dist-standalone
*.log

View file

@ -12,6 +12,7 @@
],
"scripts": {
"build": "tsup",
"build:standalone": "tsup --config tsup.standalone.config.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"

View file

@ -0,0 +1,17 @@
import { defineConfig } from "tsup";
// Self-contained build of the transport-plugin entry for distribution inside
// the Python wheel (headroom/providers/opencode/_dist/). The regular build
// (tsup.config.ts) leaves npm deps external and only runs from a checkout
// with node_modules present; pip installs have no node_modules, so this
// variant bundles every dependency into a single loadable file.
export default defineConfig({
entry: { "entry.opencode": "src/entry.opencode.ts" },
outDir: "dist-standalone",
format: ["esm"],
splitting: false,
dts: false,
sourcemap: false,
clean: true,
noExternal: [/.*/],
});

View file

@ -0,0 +1,50 @@
"""Resolution of the OpenCode transport-plugin path across install layouts."""
from __future__ import annotations
from pathlib import Path
import pytest
import headroom.providers.opencode.runtime as oc_runtime
from headroom.providers.opencode.runtime import headroom_opencode_plugin_path
_PACKAGED = Path(oc_runtime.__file__).resolve().parent / "_dist" / "entry.opencode.js"
_REPO_BUILD = (
Path(oc_runtime.__file__).resolve().parents[3]
/ "plugins"
/ "opencode"
/ "dist"
/ "entry.opencode.js"
)
def test_packaged_bundle_is_committed_and_self_contained() -> None:
# The wheel picks this file up via maturin's python-source packaging; if
# it goes missing, pip installs silently lose all-provider routing again.
assert _PACKAGED.is_file(), "committed wheel bundle missing - run npm run build:standalone"
text = _PACKAGED.read_text(encoding="utf-8")
assert len(text) > 10_000, "bundle suspiciously small - not the standalone build?"
# Self-contained: no bare npm imports; node builtins are the only imports
# allowed (site-packages has no node_modules to resolve anything else).
assert 'from "headroom-ai"' not in text
assert 'from "@opencode-ai/plugin"' not in text
def test_plugin_path_env_override_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
override = tmp_path / "custom.js"
override.write_text("// plugin")
monkeypatch.setenv("HEADROOM_OPENCODE_PLUGIN_PATH", str(override))
assert headroom_opencode_plugin_path() == str(override)
def test_plugin_path_falls_back_to_packaged_bundle(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("HEADROOM_OPENCODE_PLUGIN_PATH", raising=False)
resolved = headroom_opencode_plugin_path()
assert resolved is not None
# In a repo checkout with a built plugins/opencode/dist the repo build wins
# (fresher during development); otherwise the packaged bundle must resolve.
if _REPO_BUILD.is_file():
assert resolved == str(_REPO_BUILD)
else:
assert resolved == str(_PACKAGED)