mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
f74d874777
commit
f54f04f5bf
7 changed files with 12999 additions and 7 deletions
12
.github/workflows/opencode-plugin.yml
vendored
12
.github/workflows/opencode-plugin.yml
vendored
|
|
@ -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; }
|
||||
|
|
|
|||
12898
headroom/providers/opencode/_dist/entry.opencode.js
Normal file
12898
headroom/providers/opencode/_dist/entry.opencode.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
1
plugins/opencode/.gitignore
vendored
1
plugins/opencode/.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
dist-standalone
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:standalone": "tsup --config tsup.standalone.config.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
|
|
|||
17
plugins/opencode/tsup.standalone.config.ts
Normal file
17
plugins/opencode/tsup.standalone.config.ts
Normal 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: [/.*/],
|
||||
});
|
||||
50
tests/test_providers_opencode_plugin_path.py
Normal file
50
tests/test_providers_opencode_plugin_path.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue