feat(opencode): native OpenCode support — provider discovery, wrap/unwrap, MCP, install#1089
feat(opencode): native OpenCode support — provider discovery, wrap/unwrap, MCP, install#1089hareeshkar wants to merge 5 commits into
Conversation
PR governanceThis PR follows the template and is marked ready for human review. |
|
+1, please merge this ASAP |
|
Highly anticipated ! |
|
+1, please merge this ASAP |
|
+1... Cool PR, I would like to see it merged |
|
Top 1 priority to merge if u ask me. I'd like to see this merged soon |
JerrettDavis
left a comment
There was a problem hiding this comment.
This PR is not merge-ready in current GitHub state (mergeStateStatus=UNSTABLE). Please update from current main, resolve any conflicts if present, and rerun/clear required CI before this can be approved.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…normalization Reviewing chopratejas#1089 (the launcher-side opencode wrap) surfaced two gaps in the initial override implementation: 1. Gemini's version segment is /v1beta (not /v1). The proxy's gemini handlers append /v1beta/models/... themselves, so a caller passing the versioned URL (e.g. matching headroom's _KNOWN_UPSTREAMS 'generativelanguage.googleapis.com/v1beta') would have produced a doubled /v1beta/v1beta path. The resolver now strips a trailing /v1beta as well as /v1. 2. The three /v1beta gemini routes did not thread the override through. handle_gemini_generate_content and handle_gemini_count_tokens already accepted upstream_base_url; handle_gemini_stream_generate_content did not. All three routes now pass request_upstream_override(request), and the stream handler gained the parameter. Coverage is now OpenAI (/v1/chat/completions, /v1/responses), Anthropic (/v1/messages), Gemini (/v1beta generateContent/streamGenerateContent/ countTokens), and every passthrough / catch-all route.
|
Thanks for putting this together. My read is that #1089 and #1105 overlap on native OpenCode support, but they are not the same shape of change. #1089 is mainly a runtime routing PR: it discovers OpenCode provider/auth config, maps providers to upstream base URLs, starts one proxy per upstream, supports custom provider URLs, and handles multi-provider port assignment. That is the strongest part of this PR. #1105 goes further on the product/integration surface around that runtime:
So I would summarize it as: #1089 is stronger on dynamic per-upstream routing, while #1105 is broader as a mergeable end-to-end OpenCode integration. #1105 covers runtime wrapping plus install/uninstall, MCP, plugin packaging, Docker e2e, and integration with the existing Headroom deployment model. |
Adds first-class OpenCode integration to Headroom: - Provider discovery from auth.json and opencode.json (14+ known upstreams including opencode-go and opencode Zen) - headroom wrap opencode / headroom unwrap opencode CLI commands with full flag surface (--routing-mode, --no-rtk, --no-mcp, --no-serena, --code-graph, --memory, --learn, --backend) - Two routing modes: multi-proxy (one proxy per upstream, default) and single-proxy (shared proxy with x-headroom-base-url header routing via 3 proxy route changes) - OpencodeRegistrar (MCP server registration via opencode.json) - Install infrastructure (ToolTarget.OPENCODE, provider scope, backup/restore with marker-based config blocks) - Centralized port management (port_utils.py with allocate_ports, is_headroom_proxy, find_opencode_ports) - JSONC comment stripping (placeholder-based, handles URLs in strings) - Telemetry tracking for opencode wrap agent - 96 new tests across 5 test files Co-Authored-By: opencode <noreply@opencode.ai>
|
Leaving a cross-link because this overlaps with #1105. I ended up taking #1105 in a different direction: do not patch provider URLs at all. The OpenCode plugin installs a transport shim and routes outbound It also handles the subagent case. The plugin preloads the same shim into child Node processes through The fail-closed behavior matters here. External HTTP/2 is blocked instead of being allowed to bypass Headroom. Local OpenCode calls and Headroom proxy calls are skipped so the shim does not loop into itself. #1105 is green in Docker: full Python suite |
Documentation: - docs/content/docs/opencode.mdx: comprehensive integration guide covering provider discovery, OpenCode Go support, routing modes, architecture, CLI options, environment variables, persistent installs, and troubleshooting. - docs/content/docs/meta.json: add opencode to navigation. Docker e2e: - e2e/wrap/Dockerfile: install OpenCode binary, add to PATH. - e2e/wrap/run.py: add verify_opencode_wrap() with superior checks: multiple providers discovered, config backup created, MCP entry in overlay, unwrap restores from backup, unwrap removes backup file. Adversarial review fixes: - B1: consolidate _opencode_home_dir into single canonical location in config.py, import everywhere else (was duplicated 3 times). - B2: fix TOCTOU race in allocate_ports — use bind/release instead of connect (prevents intermittent port conflicts). - B3: fix missing normalize_upstream_base in catch-all passthrough route (prevented /v1/v1 path doubling in single-proxy mode). - B4: fix single-proxy phantom port allocation — all providers now share the single proxy port (no more unallocated dedicated ports). Co-Authored-By: opencode <noreply@opencode.ai>
These files were accidentally staged from a prior branch state. The opencode PR does not include a transport shim or serena config. Co-Authored-By: opencode <noreply@opencode.ai>
|
this PR is re #1193 @rudironsoni @hareeshkar its not only about opencode go / zen model endpoints As an OpenCode CLI user, I configure my own models. It would be useful to support Headroom integration with OpenCode CLI, similar how OpenChamber integrates with OpenCode. |
|
Yes, this is exactly what my PR does: #1105 It keeps the OpenCode config untouched and puts Headroom in the middle through the OpenCode plugin. Requests are intercepted at runtime, so providers added mid-session are still routed through Headroom. It also covers child Node processes and subagents, so the wrap is full and transparent instead of depending on a static provider snapshot. |
d70c13a to
7923285
Compare
|
Updated this PR to address the feedback. Here's what changed: What This PR ProvidesInfrastructure layer for OpenCode integration:
Relationship to #1105@rudironsoni's #1105 provides transparent routing via a transport shim that intercepts ALL HTTP traffic. This is the right approach for transparent wrapping — it automatically supports OpenCode Go, all providers, and mid-session provider changes. This PR provides a different approach: per-provider config overlay where users see their actual providers in the model picker. The two approaches have different tradeoffs:
These are complementary approaches, not competing ones. |
…nterception Closes the mid-session provider gap (headroomlabs-ai#1089). Previously, providers added via /connect after OpenCode launched bypassed the Headroom proxy entirely. The fix: a ~80-line ESM module (shim.mjs) is injected into the OpenCode Node process via NODE_OPTIONS=--import=<path> before any userland code runs. It patches globalThis.fetch — the only transport the Vercel AI SDK uses — to rewrite external AI calls to the local Headroom proxy and carry the original upstream origin as x-headroom-base-url. The proxy's existing route handlers already read that header (lines 428, 482, 585, 884 of proxy_routes.py) so no proxy changes are needed. Design choices vs alternatives: - Patches ONLY globalThis.fetch (not http/https/http2/child_process). The AI SDK uses the Web Fetch API exclusively; patching Node core transports breaks git, npm, and other tools. - Cold-start retry: 3× at 75ms delays (mirrors OpenChamber's READINESS_HOLD_POLL_MS pattern) so the first request never fails if the proxy is still warming up. - Loopback guard prevents proxy→proxy routing loops. - Fail-open on URL parse errors (non-URL strings pass through unchanged). - Known providers still listed in OPENCODE_CONFIG_CONTENT so the model picker shows real names (deepseek, opencode-go) not a wrapper name. - Single-proxy mode is now the default (was multi). 5 new tests added; full opencode suite: 110 passed, 1 warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## 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>
JerrettDavis
left a comment
There was a problem hiding this comment.
The current head is closer, but I still need to request changes for two correctness issues.
First, the fetch shim retries any proxied response with status 502 or 503:
const res = await _fetch(url, init);
if (res.status < 502 || i === attempts - 1) return res;That can duplicate non-idempotent provider calls. Once the local proxy is reachable and returns a response, a 502/503 may be the real upstream provider response after the request was forwarded, not just “proxy warming up.” Retrying a POST can create duplicate completions/tool calls and duplicate billing. Please restrict cold-start retry to connection-refused/proxy-not-listening style failures before the proxy accepts the request, or otherwise prove the response was generated locally before any upstream forwarding. Add shim coverage for this behavior.
Related shim coverage gap: headroomFetch(input, init = {}) rewrites Request inputs to a URL string and then calls fetchWithRetry(proxied.toString(), { ...init, headers }). If callers pass a Request object without an explicit init, the original method/body are not carried over, so a routed POST Request can become a GET with no body. The shim should preserve Request method/body/credentials/etc. when rewriting, with tests.
Second, provider-scope install rollback does not clean up a Headroom provider when there was no pre-existing OpenCode config to back up. apply_provider_scope() writes a normal JSON provider.headroom entry, but revert_provider_scope() without a backup only calls strip_opencode_headroom_blocks(), which removes marker-wrapped text blocks. The installed provider entry is not marker-wrapped, so uninstall leaves Headroom configured instead of removing the generated config/file. Please either write a removable marker block or remove provider.headroom structurally on revert, including the “config did not exist before install” case.
The branch is also DIRTY and currently only has governance checks on the latest head, so after the fixes it needs a refresh/full CI run before merge.
|
Maintainer refresh: merged current main into this branch. The older native provider/shim implementation is superseded by the OpenCode plugin path already on main, so the refreshed PR has no remaining code delta against main and does not carry forward the unsafe fetch shim or provider-overlay behavior from the earlier revision. Focused local validation passed: OpenCode wrap, MCP registrar, provider config, and provider install tests (103 passed). |
|
Thank you for the OpenCode work here. We refreshed this branch against current main and confirmed there is no remaining diff: the OpenCode implementation is now covered by the plugin-based path already landed on main, and the older native shim/provider-overlay approach is no longer part of the branch result. Closing this PR as superseded by main while preserving the contribution history in the branch. |
Description
Adds native OpenCode support to Headroom: `headroom wrap opencode` and `headroom unwrap opencode` with transparent fetch-level interception, full provider discovery, MCP registration, install infrastructure, and centralized port management.
Why: Headroom has no OpenCode integration today. Users must manually configure `headroom proxy` and set `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. This PR makes OpenCode a first-class target and covers all providers — including those added mid-session via `/connect` — without touching the user's on-disk config.
Closes #1193
Type of Change
Architecture: How It Works
Mid-session
/connectprovider: User adds a new provider mid-session. The shim already intercepts all outbound HTTPS calls — no config change, no restart required.Known providers at launch: Still injected via
OPENCODE_CONFIG_CONTENToverlay so OpenCode's model picker shows real provider names (deepseek,opencode-go) not a generic wrapper.Comparison to Transport-Level Interception Alternatives
globalThis.fetchonlyauth.json(14+ upstreams)deepseek,opencode-go)/connectprovidersopencode-go)allocate_ports()bind/releaseToolTarget.OPENCODEChanges Made
headroom/providers/opencode/shim.mjs, ~80 lines): ESM module loaded viaNODE_OPTIONS=--import. PatchesglobalThis.fetchto route external AI calls through Headroom proxy withx-headroom-base-urlheader. Cold-start retry (3× at 75ms) mirrors OpenChamber'sREADINESS_HOLD_POLL_MSpattern.headroom/providers/opencode/runtime.py):build_provider_upstream_map()discovers providers from 3 sources —opencode.jsoncustom providers,auth.jsonAPI entries, and 14-entry known upstreams table. Supports OpenCode Zen (opencode.ai/zen/v1) and OpenCode Go (opencode.ai/zen/go/v1). Filters OAuth providers and localhost URLs.headroom/providers/opencode/runtime.py):build_launch_env()injectsHEADROOM_PROXY_URLandNODE_OPTIONS=--import=<shim.mjs>in single-proxy mode (default). Usesimportlib.resources.files()to locate the bundled shim.headroom/providers/opencode/runtime.py):build_overlay()generatesOPENCODE_CONFIG_CONTENTJSON with per-provider routing. In single mode all providers share one port;x-headroom-base-urlcarries upstream identity.headroom/port_utils.py): CentralizedDEFAULT_PROXY_PORT,allocate_ports()(bind-and-release, no TOCTOU race),is_headroom_proxy()(HTTP/healthzprobe),find_opencode_ports()(multi-port discovery for unwrap).headroom/providers/proxy_routes.py): 3 route handlers checkx-headroom-base-url—/v1/messages(Anthropic),/v1/chat/completions(OpenAI),/v1beta/models/{model}:generateContent(Gemini). Catch-all passthrough also normalized.headroom/proxy/helpers.py):normalize_upstream_base()strips/v1and/v1betasuffixes to prevent path doubling.headroom/mcp_registry/opencode.py):OpencodeRegistrar(MCPRegistrar)— detect/get/register/unregister with JSONC-aware parsing.headroom/install/):ToolTarget.OPENCODEenum,opencode_config_path(), provider scope inSUPPORTED_TARGETS, wired into_ENV_BUILDERSand_PROVIDER_SCOPE_HANDLERS.headroom/cli/wrap.py):wrap opencodeandunwrap opencodewith all standard flags.docs/content/docs/opencode.mdx): Integration guide covering provider discovery, OpenCode Go, routing modes, architecture, CLI options, troubleshooting.e2e/wrap/): OpenCode binary in Dockerfile,verify_opencode_wrap()with 12 assertions.headroom/telemetry/context.py):opencodeadded to_KNOWN_WRAP_AGENTS.Testing
pytest)ruff check .)mypy headroom) — pre-existing issues in codebaseTest Output
New shim tests:
Real Behavior Proof
Environment: macOS Darwin 24.6.0, Python 3.10.20 (.venv), Node.js 22, OpenCode 1.14.41 at
~/.opencode/bin/opencode. Realauth.jsonwith 10 provider accounts (opencode, opencode-go, deepseek, openai, google, minimax, minimax-coding-plan, minimax-cn-coding-plan, nvidia, github-copilot). Customopencode.jsonwithmimoprovider (@ai-sdk/openai-compatible,baseURL: https://token-plan-sgp.xiaomimimo.com/v1, models: mimo-v2.5-pro, mimo-v2.5).Exact command / steps: (1) Verified provider discovery:
build_provider_upstream_map()→['deepseek', 'google', 'mimo', 'openai', 'opencode', 'opencode-go']— 6 providers,github-copilotcorrectly excluded (OAuth). (2) Verified overlay:build_launch_env(8787, routing_mode='single')→OPENCODE_CONFIG_CONTENTwith all 6 providers pointing tohttp://127.0.0.1:8787/v1, each withx-headroom-base-urlto real upstream.NODE_OPTIONS=--import=.../shim.mjs,HEADROOM_PROXY_URL=http://127.0.0.1:8787. (3) Startedheadroom proxy --port 58790. (4) Non-streaming request via curl:curl -X POST http://127.0.0.1:58790/v1/chat/completions -H "x-headroom-base-url: https://token-plan-sgp.xiaomimimo.com/v1" -H "Authorization: Bearer <mimo-key>" -d '{"model":"mimo-v2.5-pro","messages":[{"role":"user","content":"Say hello in exactly 3 words."}],"stream":false}'. (5) Streaming request via curl — same endpoint,"stream":true. (6) Shim e2e:NODE_OPTIONS="--import=shim.mjs" HEADROOM_PROXY_URL="http://127.0.0.1:58790" node -e "const res = await fetch('https://token-plan-sgp.xiaomimimo.com/v1/chat/completions', {method:'POST', headers:{...}, body: JSON.stringify({model:'mimo-v2.5-pro', messages:[{role:'user',content:'What is 1+1?'}], stream:false})}); console.log(await res.json())".Observed result: (4) Non-streaming response from mimo-v2.5-pro through Headroom proxy —
HTTP 200,{"choices":[{"message":{"content":"Hello, nice day!","role":"assistant"}}],"model":"mimo-v2.5-pro","usage":{"total_tokens":279}}. (5) Streaming — SSE chunks received withreasoning_content(mimo-v2.5-pro is a reasoning model, shows chain-of-thought inreasoning_content). (6) Shim e2e —fetch('https://token-plan-sgp.xiaomimimo.com/...')intercepted by shim, rewritten tohttp://127.0.0.1:58790/v1/chat/completionswithx-headroom-base-urlheader, proxy forwarded to real mimo API —HTTP 200,total_tokens: 294, reasoning:"The answer is simply 2". The shim correctly skipped loopback calls and avoided proxy→proxy loops. Provider names (deepseek,opencode-go,mimo) visible in OPENCODE_CONFIG_CONTENT overlay.Not tested: Docker e2e (Docker Desktop daemon not running on dev machine; e2e logic verified directly on host with isolated temp directory — 12/12 assertions passed). Type checking (pre-existing mypy issues in codebase unrelated to this PR). Interactive OpenCode TUI session end-to-end (provider routing verified via Node.js shim test which replicates the exact fetch path OpenCode uses).
Review Readiness
Checklist
Additional Notes
The fetch shim patches only
globalThis.fetch— the single transport the Vercel AI SDK uses. Patchinghttp/https/http2/child_processis unnecessary and breaks unrelated tools (git, npm, native Node http calls). The shim is ~80 lines of plain ESM with no npm dependencies, embedded directly in the Python package viaimportlib.resources.files().