mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
5 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e8151f059b
|
fix(opencode): expose headroom/* models in injected provider config (#1716)
## Description `headroom wrap opencode` (and `headroom install opencode`) injects a `provider.headroom` block into the OpenCode config, but the block contained **no `models` map**. OpenCode only resolves `<provider>/<model>` ids that are listed in a custom provider's `models` map, so every documented `headroom/*` model (see `plugins/opencode/README.md`) failed with: ```text Error: Model not found: headroom/claude-sonnet-4-6. ``` This PR adds the model map (mirroring `DEFAULT_MODELS` in `plugins/opencode/src/provider.ts` and the README table) via a single shared `headroom_provider_entry()` helper used by all three injection sites. It also fixes a latent bug in the TS helper `createHeadroomProvider`, which prefixed model **keys** with `headroom/` — OpenCode would have registered them as `headroom/headroom/<id>`. Not addressed here (flagged for maintainers): the `headroom-opencode` npm package referenced by the plugin docs is not published to npm (registry 404), so the transparent-transport interception path (which would capture `github-copilot/*` traffic in the dashboard) still depends on a locally built `plugins/opencode/dist/entry.opencode.js`. With this fix, the documented `headroom/*` provider route works, so wrapped OpenCode traffic is proxied and recorded when users select `headroom/*` models. Closes #1657 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] 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 - `headroom/providers/opencode/config.py`: added `HEADROOM_OPENCODE_MODELS` (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001, gpt-4o, gpt-4.1 — same names/limits as the TS plugin) and a `headroom_provider_entry(port)` helper that includes the `models` map; `_render_provider_block` and `inject_opencode_provider_config` now use it instead of duplicating the provider dict. - `headroom/providers/opencode/runtime.py`: `build_opencode_config_content` reuses `headroom_provider_entry()` so `OPENCODE_CONFIG_CONTENT` exposes the models too. - `plugins/opencode/src/provider.ts`: `createHeadroomProvider` no longer prefixes model keys with `headroom/` (OpenCode namespaces model ids by provider key; keys must be bare ids). - `tests/test_providers_opencode_config.py`: assertions that the injected provider block and `build_opencode_config_content` output contain a `models` map with bare-id keys including `claude-sonnet-4-6`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_providers_opencode_config.py -q 1 failed, rest passed — test_build_launch_env_with_project is a pre-existing Windows-only failure (json.dumps escapes backslashes in the plugin path); it fails identically on upstream/main without this change and passes on Linux. $ ruff check headroom/providers/opencode tests/test_providers_opencode_config.py All checks passed! $ ruff format --check . 5 files already formatted $ mypy headroom --ignore-missing-imports Success (notes only, no errors) $ cd plugins/opencode && npm run typecheck && npm test tsc --noEmit: OK Test Files 2 passed (2) Tests 13 passed (13) ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, Node v26.3.0, this branch with the Rust core built locally. - Exact command / steps: `python -c "from headroom.providers.opencode.runtime import build_opencode_config_content; import json; print(json.dumps(build_opencode_config_content(port=8787, include_mcp=False)['provider']['headroom'], indent=1))"` - Observed result: the generated `headroom` provider block now contains `"models"` with bare-id keys (`claude-sonnet-4-6`, `claude-opus-4-6`, `claude-haiku-4-5-20251001`, `gpt-4o`, `gpt-4.1`), each with name and context/output limits; previously the block had no `models` key, which is exactly why OpenCode returned `Model not found: headroom/claude-sonnet-4-6`. - Not tested: a live `opencode run` round-trip against a real OpenCode install (no OpenCode binary in this environment); dashboard event capture for `github-copilot/*` models via the transport plugin (blocked on the unpublished `headroom-opencode` artifact, see Description). ## 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 - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Docs: `plugins/opencode/README.md` already documents these models; no doc change needed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ad0034f981
|
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) |
||
|
|
6c83790680
|
fix(opencode): write local MCP config (#1381)
## Description Fixes the OpenCode config corruption reported in #1380 for wrap, MCP registration, and provider-scope install paths. OpenCode MCP entries are local stdio servers, not remote HTTP endpoints. This changes Headroom's OpenCode MCP serialization to write `type: "local"` with `command: ["headroom", "mcp", "serve"]`, uses OpenCode's `environment` field for MCP env vars, and still reads the older `env` key for compatibility. This also stops provider-only OpenCode config injection from creating a fake `http://127.0.0.1:<port>/mcp` entry, so `headroom wrap opencode --no-mcp` no longer leaves `mcp.headroom` behind. Finally, the install CLI/docs now accept and document `--target opencode` with provider scope. This does not change the broader `headroom mcp status/uninstall` behavior from #1380; that looks like a separate follow-up. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Write OpenCode MCP entries as local stdio config instead of remote `/mcp` config. - Use `environment` for OpenCode MCP env vars while continuing to read legacy `env` entries. - Stop OpenCode provider injection/persistent provider install from adding MCP config. - Keep `--no-mcp` from writing `mcp.headroom` while preserving other MCP entries such as Serena. - Allow `headroom install apply --target opencode` at the CLI layer. - Update OpenCode docs and changelog. ## Testing - [x] Focused unit tests pass - [x] Linting passes (`ruff check .`) - [x] Formatting passes (`ruff format --check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for the fixed behavior - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_mcp_registry_opencode.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_cli/test_install_cli.py tests/test_install/test_providers.py Pytest: 164 passed $ uvx ruff check . All checks passed! $ uvx ruff format --check . 986 files already formatted $ uvx mypy --config-file pyproject.toml headroom Success: no issues found in 398 source files ``` ## Real Behavior Proof - Environment: macOS local worktree at `/Users/vinaygupta/Desktop/git/headroom-fix-opencode-mcp-config`; branch `fix-opencode-mcp-config`; commit `aea96208`. - Exact command / steps: ran the focused OpenCode/installer regression suite plus Ruff lint/format checks and mypy commands shown above. - Observed result: the focused tests pass and cover OpenCode MCP serialization as `type: "local"`, `command: ["headroom", "mcp", "serve"]`, `environment` env vars, `--no-mcp` not writing `mcp.headroom`, provider-scope install not adding MCP config, and `install apply --target opencode` being accepted. - Not tested: full `pytest` locally, because collection requires the native `headroom._core` extension in this worktree. Attempting the project runner hit a local native build failure first: `esaxx-rs` failed compiling `src/esaxx.cpp` with `fatal error: 'cstdint' file not found`. The broader generic `headroom mcp status/uninstall` behavior from #1380 is intentionally left for a follow-up. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Scope note: generic `mcp status/uninstall` support from #1380 is intentionally left as a separate follow-up PR. |
||
|
|
487aa71a3c
|
ci: restore green lint (reformat for ruff 0.15.17, fix mypy no-any-return, pin linters) (#1295)
## Description
The CI lint job (`ruff check .` → `ruff format --check .` → `mypy
headroom`) was red on `main`
and therefore on every open PR, for two unrelated reasons that the early
ruff failure was masking:
1. **ruff**: the lint job installs `ruff` unpinned, and ruff 0.15.17
began enforcing import-block
sorting (`I001`) and formatting that older ruff accepted → `ruff check
.` / `ruff format --check .`
fail on files nobody touched.
2. **mypy**: `headroom/providers/opencode/config.py` had two `return
json.loads(...)` statements in
a function declared `-> dict[str, Any]`; `json.loads` is typed `Any`, so
`mypy headroom` fails
with `no-any-return` (reproduced on mypy 1.20.2 — not a version-specific
quirk).
This restores a green lint baseline and pins both linters so a future
release can't silently break
CI again.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `.github/workflows/ci.yml`: pin `ruff==0.15.17` and `mypy==1.20.2` in
the lint job.
- Applied `ruff check --fix .` (6 `I001` import-sort fixes) and `ruff
format .` (10 files) across
the repo — import ordering and whitespace only, no behavior change.
- `headroom/providers/opencode/config.py`: narrow both
`_parse_json_loose` return sites with an
`isinstance(parsed, dict)` guard, so the `dict[str, Any]` annotation is
true at runtime
(non-dict JSON falls back to `{}`) and mypy's `no-any-return` is
resolved.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`, `ruff format --check .`, `mypy
headroom --ignore-missing-imports`)
### Test Output
```text
$ python -m ruff check .
All checks passed!
$ python -m ruff format --check .
913 files already formatted
$ python -m mypy headroom/providers/opencode/config.py --ignore-missing-imports
Success: no issues found in 1 source file
$ python -m pytest tests/test_providers_opencode_config.py -q
37 passed
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.13, ruff 0.15.17, mypy 1.20.2,
branch ci/fix-ruff-lint off
headroomlabs-ai/main
- Exact command / steps: reproduced the red lint (latest ruff: 6 `I001`
+ 10 unformatted files;
the mypy failure was read from the #1295 CI lint log —
`config.py:125,133 no-any-return`, and
re-confirmed locally on mypy 1.20.2). Applied the ruff auto-fix/format,
added the dict guard,
pinned both linters, and re-ran each lint step.
- Observed result: `ruff check .` → "All checks passed!"; `ruff format
--check .` → "913 files
already formatted"; `mypy` on the fixed file → "Success: no issues
found"; full `mypy headroom`
reports only Unix `fcntl` attributes that don't exist on this Windows
box (present on the Linux
CI runner, where the prior run showed exactly the two now-fixed errors).
37 opencode-config
tests pass.
- Not tested: did not run the full OS/Python test matrix — the change is
formatting + two CI
dependency pins + a two-line type-narrowing guard, with no runtime
behavior change for dict JSON.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
b4571cc346
|
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com> |