mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
c5493ea93b
commit
e8151f059b
4 changed files with 54 additions and 26 deletions
|
|
@ -32,6 +32,44 @@ _MCP_BLOCK_RE = re.compile(
|
||||||
)
|
)
|
||||||
HEADROOM_OPENCODE_PLUGIN = "headroom-opencode"
|
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:
|
def _opencode_home_dir() -> Path:
|
||||||
"""Return the OpenCode home/config directory."""
|
"""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:
|
def _render_provider_block(port: int) -> str:
|
||||||
"""Render a Headroom provider block as a JSON comment-wrapped snippet."""
|
"""Render a Headroom provider block as a JSON comment-wrapped snippet."""
|
||||||
provider = {
|
provider = {"headroom": headroom_provider_entry(port)}
|
||||||
"headroom": {
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Headroom Proxy",
|
|
||||||
"options": {"baseURL": f"http://127.0.0.1:{port}/v1"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines = [
|
lines = [
|
||||||
_PROVIDER_MARKER_START,
|
_PROVIDER_MARKER_START,
|
||||||
f'"provider": {json.dumps(provider, indent=2)},',
|
f'"provider": {json.dumps(provider, indent=2)},',
|
||||||
|
|
@ -203,13 +235,7 @@ def inject_opencode_provider_config(port: int) -> None:
|
||||||
data = _parse_json_loose(content)
|
data = _parse_json_loose(content)
|
||||||
|
|
||||||
# Merge provider into the JSON data structure.
|
# Merge provider into the JSON data structure.
|
||||||
provider = {
|
provider = {"headroom": headroom_provider_entry(port)}
|
||||||
"headroom": {
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Headroom Proxy",
|
|
||||||
"options": {"baseURL": f"http://127.0.0.1:{port}/v1"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data = _inject_key_into_json(data, "provider", provider)
|
data = _inject_key_into_json(data, "provider", provider)
|
||||||
|
|
||||||
# Write back as formatted JSON (opencode uses standard JSON with comments).
|
# Write back as formatted JSON (opencode uses standard JSON with comments).
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from headroom.mcp_registry.install import DEFAULT_PROXY_URL
|
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:
|
def proxy_base_url(port: int) -> str:
|
||||||
|
|
@ -74,11 +74,7 @@ def build_opencode_config_content(
|
||||||
"provider": {
|
"provider": {
|
||||||
"anthropic": {"options": {"baseURL": base_url}},
|
"anthropic": {"options": {"baseURL": base_url}},
|
||||||
"openai": {"options": {"baseURL": base_url}},
|
"openai": {"options": {"baseURL": base_url}},
|
||||||
"headroom": {
|
"headroom": headroom_provider_entry(port),
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Headroom Proxy",
|
|
||||||
"options": {"baseURL": base_url},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if include_mcp:
|
if include_mcp:
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,9 @@ export function createHeadroomProvider(
|
||||||
npm: "@ai-sdk/openai-compatible",
|
npm: "@ai-sdk/openai-compatible",
|
||||||
name: "Headroom Proxy",
|
name: "Headroom Proxy",
|
||||||
options: { baseURL: `${baseUrl}/v1` },
|
options: { baseURL: `${baseUrl}/v1` },
|
||||||
models: Object.fromEntries(
|
// OpenCode namespaces model ids by provider key, so entries must be bare
|
||||||
Object.entries(models).map(([id, mapping]) => [
|
// ids ("claude-sonnet-4-6"), referenced as "headroom/<id>".
|
||||||
`headroom/${id}`,
|
models: { ...models },
|
||||||
mapping,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,10 @@ def test_inject_provider_config_creates_file(
|
||||||
assert config_file.exists()
|
assert config_file.exists()
|
||||||
config = _parse_json_loose(config_file.read_text())
|
config = _parse_json_loose(config_file.read_text())
|
||||||
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
|
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 "mcp" not in config
|
||||||
assert "model" not in config # headroom provider is a transparent pass-through
|
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"]
|
providers = config["provider"]
|
||||||
assert providers["anthropic"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
assert providers["anthropic"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||||
assert providers["openai"]["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).
|
# The transport plugin is injected by absolute path (opencode loads it directly).
|
||||||
assert config["plugin"] == [str(plugin)]
|
assert config["plugin"] == [str(plugin)]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue