mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## 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>
92 lines
2.2 KiB
TypeScript
92 lines
2.2 KiB
TypeScript
export interface HeadroomModelMapping {
|
|
name: string;
|
|
limit: {
|
|
context: number;
|
|
output: number;
|
|
};
|
|
}
|
|
|
|
export interface HeadroomProviderOptions {
|
|
proxyBaseUrl?: string;
|
|
proxyPort?: number;
|
|
defaultModel?: string;
|
|
models?: Record<string, HeadroomModelMapping>;
|
|
}
|
|
|
|
export const DEFAULT_MODELS: Record<string, HeadroomModelMapping> = {
|
|
"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 },
|
|
},
|
|
};
|
|
|
|
export const DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
|
|
function resolveBaseUrl(options: HeadroomProviderOptions): string {
|
|
if (options.proxyBaseUrl) return options.proxyBaseUrl.replace(/\/+$/, "");
|
|
const port = options.proxyPort ?? 8787;
|
|
return `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
export interface HeadroomProvider {
|
|
npm: string;
|
|
name: string;
|
|
options: {
|
|
baseURL: string;
|
|
apiKey?: string;
|
|
};
|
|
models: Record<string, HeadroomModelMapping>;
|
|
}
|
|
|
|
export function createHeadroomProvider(
|
|
options: HeadroomProviderOptions = {},
|
|
): HeadroomProvider {
|
|
const baseUrl = resolveBaseUrl(options);
|
|
const models = options.models ?? DEFAULT_MODELS;
|
|
|
|
return {
|
|
npm: "@ai-sdk/openai-compatible",
|
|
name: "Headroom Proxy",
|
|
options: { baseURL: `${baseUrl}/v1` },
|
|
models: Object.fromEntries(
|
|
Object.entries(models).map(([id, mapping]) => [
|
|
`headroom/${id}`,
|
|
mapping,
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function buildOpencodeConfigContent(
|
|
options: HeadroomProviderOptions = {},
|
|
): Record<string, unknown> {
|
|
const defaultModel = options.defaultModel ?? DEFAULT_MODEL;
|
|
const provider = createHeadroomProvider(options);
|
|
|
|
return {
|
|
provider: { headroom: provider },
|
|
model: `headroom/${defaultModel}`,
|
|
};
|
|
}
|
|
|
|
export function buildOpencodeConfigContentJson(
|
|
options: HeadroomProviderOptions = {},
|
|
): string {
|
|
return JSON.stringify(buildOpencodeConfigContent(options));
|
|
}
|