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>
This commit is contained in:
Parideboy 2026-07-04 00:20:14 +02:00 committed by GitHub
parent c5493ea93b
commit e8151f059b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 54 additions and 26 deletions

View file

@ -32,6 +32,44 @@ _MCP_BLOCK_RE = re.compile(
)
HEADROOM_OPENCODE_PLUGIN = "headroom-opencode"
# Models exposed by the injected `headroom` provider. OpenCode only resolves
# `headroom/<id>` for ids listed in the provider's `models` map, so an empty
# map means every documented `headroom/*` model fails with "Model not found".
# Keep in sync with DEFAULT_MODELS in plugins/opencode/src/provider.ts and the
# table in plugins/opencode/README.md.
HEADROOM_OPENCODE_MODELS: dict[str, Any] = {
"claude-sonnet-4-6": {
"name": "Claude Sonnet 4.6",
"limit": {"context": 200000, "output": 16384},
},
"claude-opus-4-6": {
"name": "Claude Opus 4.6",
"limit": {"context": 200000, "output": 16384},
},
"claude-haiku-4-5-20251001": {
"name": "Claude Haiku 4.5",
"limit": {"context": 200000, "output": 8192},
},
"gpt-4o": {
"name": "GPT-4o",
"limit": {"context": 128000, "output": 16384},
},
"gpt-4.1": {
"name": "GPT-4.1",
"limit": {"context": 1048576, "output": 32768},
},
}
def headroom_provider_entry(port: int) -> dict[str, Any]:
"""Return the `headroom` provider block pointed at the local proxy."""
return {
"npm": "@ai-sdk/openai-compatible",
"name": "Headroom Proxy",
"options": {"baseURL": f"http://127.0.0.1:{port}/v1"},
"models": HEADROOM_OPENCODE_MODELS,
}
def _opencode_home_dir() -> Path:
"""Return the OpenCode home/config directory."""
@ -83,13 +121,7 @@ def strip_opencode_headroom_blocks(content: str, *, remove_mcp: bool = True) ->
def _render_provider_block(port: int) -> str:
"""Render a Headroom provider block as a JSON comment-wrapped snippet."""
provider = {
"headroom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Headroom Proxy",
"options": {"baseURL": f"http://127.0.0.1:{port}/v1"},
}
}
provider = {"headroom": headroom_provider_entry(port)}
lines = [
_PROVIDER_MARKER_START,
f'"provider": {json.dumps(provider, indent=2)},',
@ -203,13 +235,7 @@ def inject_opencode_provider_config(port: int) -> None:
data = _parse_json_loose(content)
# Merge provider into the JSON data structure.
provider = {
"headroom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Headroom Proxy",
"options": {"baseURL": f"http://127.0.0.1:{port}/v1"},
}
}
provider = {"headroom": headroom_provider_entry(port)}
data = _inject_key_into_json(data, "provider", provider)
# Write back as formatted JSON (opencode uses standard JSON with comments).

View file

@ -9,7 +9,7 @@ from pathlib import Path
from headroom.mcp_registry.install import DEFAULT_PROXY_URL
from .config import HEADROOM_OPENCODE_PLUGIN
from .config import HEADROOM_OPENCODE_PLUGIN, headroom_provider_entry
def proxy_base_url(port: int) -> str:
@ -74,11 +74,7 @@ def build_opencode_config_content(
"provider": {
"anthropic": {"options": {"baseURL": base_url}},
"openai": {"options": {"baseURL": base_url}},
"headroom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Headroom Proxy",
"options": {"baseURL": base_url},
},
"headroom": headroom_provider_entry(port),
}
}
if include_mcp:

View file

@ -64,12 +64,9 @@ export function createHeadroomProvider(
npm: "@ai-sdk/openai-compatible",
name: "Headroom Proxy",
options: { baseURL: `${baseUrl}/v1` },
models: Object.fromEntries(
Object.entries(models).map(([id, mapping]) => [
`headroom/${id}`,
mapping,
]),
),
// OpenCode namespaces model ids by provider key, so entries must be bare
// ids ("claude-sonnet-4-6"), referenced as "headroom/<id>".
models: { ...models },
};
}

View file

@ -164,6 +164,10 @@ def test_inject_provider_config_creates_file(
assert config_file.exists()
config = _parse_json_loose(config_file.read_text())
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
# Bare model ids: OpenCode resolves them as "headroom/<id>" (#1657).
models = config["provider"]["headroom"]["models"]
assert "claude-sonnet-4-6" in models
assert all(not model_id.startswith("headroom/") for model_id in models)
assert "mcp" not in config
assert "model" not in config # headroom provider is a transparent pass-through
@ -419,6 +423,11 @@ def test_build_opencode_config_content_without_mcp(
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 headroom provider exposes explicit models so "headroom/<id>" resolves (#1657).
assert providers["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
models = providers["headroom"]["models"]
assert "claude-sonnet-4-6" in models
assert all(not model_id.startswith("headroom/") for model_id in models)
# The transport plugin is injected by absolute path (opencode loads it directly).
assert config["plugin"] == [str(plugin)]