headroom/plugins/opencode
Rudimar Ronsoni 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>
2026-06-22 11:07:12 -05:00
..
hook-shim feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
src feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
.gitignore feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
package.json feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
README.md feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
tsconfig.json feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
tsup.config.ts feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00
vitest.config.ts feat: headroom wrap opencode / unwrap opencode CLI (#1105) 2026-06-22 11:07:12 -05:00

headroom-opencode

Headroom proxy integration for OpenCode. Routes LLM traffic through the Headroom proxy for token compression, provides CCR retrieval, and handles provider configuration.

Install

npm install headroom-opencode

Quick start

Create a Headroom provider for opencode.json

import { createHeadroomProvider } from "headroom-opencode";

const provider = createHeadroomProvider({
  proxyPort: 8787,
});

// Write to opencode.json:
// {
//   "provider": { "headroom": provider },
//   "model": "headroom/claude-sonnet-4-6"
// }

Build OPENCODE_CONFIG_CONTENT

import { buildOpencodeConfigContentJson } from "headroom-opencode";

const json = buildOpencodeConfigContentJson({
  proxyPort: 8787,
  defaultModel: "claude-sonnet-4-6",
});

// Set as env var: process.env.OPENCODE_CONFIG_CONTENT = json;

Compress messages through the proxy

import { compressWithHeadroom } from "headroom-opencode";

const result = await compressWithHeadroom(messages, {
  model: "gpt-4o",
  proxyUrl: "http://localhost:8787",
});

console.log(`Saved ${result.tokensSaved} tokens`);

CCR retrieve tool

import { createHeadroomRetrieveTool } from "headroom-opencode";

const retrieveTool = createHeadroomRetrieveTool({
  proxyBaseUrl: "http://localhost:8787",
});

// Register in OpenCode's MCP config under mcp.headroom_retrieve

API

createHeadroomProvider(options?)

Creates a provider object compatible with OpenCode's @ai-sdk/openai-compatible format.

Option Default Description
proxyBaseUrl http://127.0.0.1:8787 Full proxy base URL
proxyPort 8787 Proxy port (ignored if proxyBaseUrl is set)
models See below Custom model mappings
defaultModel claude-sonnet-4-6 Default model ID

buildOpencodeConfigContent(options?)

Returns a full OPENCODE_CONFIG_CONTENT JSON object with provider and model.

buildOpencodeConfigContentJson(options?)

Same as above but returns a JSON string ready for the OPENCODE_CONFIG_CONTENT env var.

compressWithHeadroom(messages, options?)

Compresses an array of messages through the Headroom proxy. Returns compression stats and compressed messages.

createHeadroomRetrieveTool(config)

Creates a CCR retrieve tool for OpenCode's MCP system.

setDefaultProxyUrl(url) / getDefaultProxyUrl()

Set or get the default proxy URL for all operations. Defaults to HEADROOM_BASE_URL env var or http://localhost:8787.

Default models

Model ID Context Output
claude-sonnet-4-6 200K 16K
claude-opus-4-6 200K 16K
claude-haiku-4-5-20251001 200K 8K
gpt-4o 128K 16K
gpt-4.1 1M 32K

OpenCode plugin

The package default export is an OpenCode plugin. It adds a headroom_retrieve tool and Headroom metadata for shell commands:

{
  "plugin": [["headroom-opencode", { "proxyUrl": "http://127.0.0.1:8787" }]]
}

The plugin does not set OPENAI_BASE_URL or ANTHROPIC_BASE_URL. Model traffic is routed by the headroom provider config generated by buildOpencodeConfigContent or headroom wrap opencode.

License

Apache-2.0