headroom/tests/test_providers_opencode_plugin_path.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

51 lines
2 KiB
Python
Raw Normal View History

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>
2026-07-27 15:40:43 +02:00
"""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)