mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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>
This commit is contained in:
parent
95b2333ee5
commit
b4571cc346
57 changed files with 4655 additions and 62 deletions
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/cache
|
||||
/project.local.yml
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# the name by which the project can be referenced within Serena
|
||||
project_name: "feature-opencode-wrap"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!-- headroom:rtk-instructions -->
|
||||
# RTK (Rust Token Killer) - Token-Optimized Commands
|
||||
|
||||
When running shell commands, **always prefix with `rtk`**. This reduces context
|
||||
usage by 60-90% with zero behavior change. If rtk has no filter for a command,
|
||||
it passes through unchanged — so it is always safe to use.
|
||||
|
||||
## Key Commands
|
||||
```bash
|
||||
# Git (59-80% savings)
|
||||
rtk git status rtk git diff rtk git log
|
||||
|
||||
# Files & Search (60-75% savings)
|
||||
rtk ls <path> rtk read <file> rtk grep <pattern>
|
||||
rtk find <pattern> rtk diff <file>
|
||||
|
||||
# Test (90-99% savings) — shows failures only
|
||||
rtk pytest tests/ rtk cargo test rtk test <cmd>
|
||||
|
||||
# Build & Lint (80-90% savings) — shows errors only
|
||||
rtk tsc rtk lint rtk cargo build
|
||||
rtk prettier --check rtk mypy rtk ruff check
|
||||
|
||||
# Analysis (70-90% savings)
|
||||
rtk err <cmd> rtk log <file> rtk json <file>
|
||||
rtk summary <cmd> rtk deps rtk env
|
||||
|
||||
# GitHub (26-87% savings)
|
||||
rtk gh pr view <n> rtk gh run list rtk gh issue list
|
||||
|
||||
# Infrastructure (85% savings)
|
||||
rtk docker ps rtk kubectl get rtk docker logs <c>
|
||||
|
||||
# Package managers (70-90% savings)
|
||||
rtk pip list rtk pnpm install rtk npm run <script>
|
||||
```
|
||||
|
||||
## Rules
|
||||
- In command chains, prefix each segment: `rtk git add . && rtk git commit -m "msg"`
|
||||
- For debugging, use raw command without rtk prefix
|
||||
- `rtk proxy <cmd>` runs command without filtering but tracks usage
|
||||
<!-- /headroom:rtk-instructions -->
|
||||
17
Makefile
17
Makefile
|
|
@ -22,6 +22,10 @@ help:
|
|||
@echo " make lint - cargo clippy --workspace -- -D warnings"
|
||||
@echo " make clean - cargo clean"
|
||||
@echo ""
|
||||
@echo "E2e targets:"
|
||||
@echo " make build-e2e-wrap - build the wrap-e2e Docker image"
|
||||
@echo " make run-e2e-wrap - build + run the wrap-e2e Docker container"
|
||||
@echo ""
|
||||
@echo "Pre-push verification (run BEFORE git push to catch CI failures locally):"
|
||||
@echo " make ci-precheck - run all CI gates (rust + python + commitlint)"
|
||||
@echo " make ci-precheck-rust - cargo fmt --check + clippy + test"
|
||||
|
|
@ -138,3 +142,16 @@ ci-precheck-commitlint:
|
|||
|
||||
install-git-hooks:
|
||||
@scripts/install-git-hooks.sh
|
||||
|
||||
# ─── E2e Docker targets ────────────────────────────────────────────────────
|
||||
#
|
||||
# The wrap-e2e Dockerfile uses manylinux_2_28_x86_64 as its builder stage,
|
||||
# which only ships amd64 binaries. Pass --platform linux/amd64 explicitly
|
||||
# so the build works on Apple Silicon (requires QEMU emulation). On native
|
||||
# x86_64 hosts the flag is harmless and matches CI behaviour.
|
||||
|
||||
build-e2e-wrap:
|
||||
docker build --platform linux/amd64 -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
|
||||
|
||||
run-e2e-wrap: build-e2e-wrap
|
||||
docker run --rm headroom-wrap-e2e
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"strands",
|
||||
"litellm",
|
||||
"claude-code-vertex",
|
||||
"opencode",
|
||||
"mcp",
|
||||
"---Configuration---",
|
||||
"configuration",
|
||||
|
|
|
|||
120
docs/content/docs/opencode.mdx
Normal file
120
docs/content/docs/opencode.mdx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
title: OpenCode Integration
|
||||
description: Route OpenCode traffic through Headroom for token compression, MCP tools, and cached model access. One command to wrap, one to unwrap.
|
||||
---
|
||||
|
||||
Use `headroom wrap opencode` to route all OpenCode LLM traffic through the Headroom proxy with a single command. The proxy compresses context, injects MCP tools, and routes API calls to your configured backend.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
headroom wrap opencode
|
||||
```
|
||||
|
||||
This starts the Headroom proxy, injects a `headroom` provider into OpenCode's config, registers Headroom MCP tools, sets up RTK context filtering, and launches OpenCode through the proxy.
|
||||
|
||||
When you're done:
|
||||
|
||||
```bash
|
||||
headroom unwrap opencode
|
||||
```
|
||||
|
||||
## What `wrap opencode` does
|
||||
|
||||
| Step | What happens |
|
||||
|---|---|
|
||||
| Provider injection | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json`, pointing at `http://127.0.0.1:<port>/v1` |
|
||||
| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` with provider + model + MCP config so OpenCode picks up the proxy provider at launch |
|
||||
| Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so OpenCode `/connect` providers keep their own routing |
|
||||
| Context tool | Injects RTK (or `lean-ctx`) instructions into `~/.config/opencode/AGENTS.md` and project `AGENTS.md` |
|
||||
| MCP setup | Registers the Headroom MCP server (`headroom_compress`, `headroom_retrieve`, `headroom_stats`) |
|
||||
| Serena MCP | Optionally registers Serena code graph tools (`--no-serena` to skip) |
|
||||
| Backup | Snapshots `opencode.json` to `opencode.json.headroom-backup` before making any changes |
|
||||
| Launch | Starts the `opencode` binary through the proxy |
|
||||
|
||||
## Options
|
||||
|
||||
```bash
|
||||
headroom wrap opencode \
|
||||
--port 8787 \ # Proxy port (default: random available port)
|
||||
--no-rtk \ # Skip RTK context tool injection
|
||||
--no-mcp \ # Skip headroom MCP registration
|
||||
--no-serena \ # Skip Serena code graph MCP
|
||||
--code-graph \ # Include code graph in context
|
||||
--no-proxy \ # Use existing proxy instead of starting one
|
||||
--learn \ # Enable memory and live learning
|
||||
--memory \ # Enable persistent memory
|
||||
--backend anthropic \ # Set backend: anthropic, openai, anyllm
|
||||
--anyllm-provider ... \ # AnyLLM provider selection
|
||||
--region ... \ # Provider region
|
||||
-- <opencode args> # Arguments passed to opencode binary
|
||||
```
|
||||
|
||||
## Provider model mapping
|
||||
|
||||
The `headroom` provider exposes these models, all routed through the proxy:
|
||||
|
||||
| Provider model | Upstream model |
|
||||
|---|---|
|
||||
| `headroom/claude-sonnet-4-6` | Claude Sonnet 4.6 (200K context, 16K output) |
|
||||
| `headroom/claude-opus-4-6` | Claude Opus 4.6 (200K context, 16K output) |
|
||||
| `headroom/claude-haiku-4-5-20251001` | Claude Haiku 4.5 (200K context, 8K output) |
|
||||
| `headroom/gpt-4o` | GPT-4o (128K context, 16K output) |
|
||||
| `headroom/gpt-4.1` | GPT-4.1 (1M context, 32K output) |
|
||||
|
||||
The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json` or via `OPENCODE_CONFIG_CONTENT`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, model, and MCP config injected by wrap |
|
||||
| `HEADROOM_PROXY_URL` | Optional metadata used by the `headroom-opencode` plugin |
|
||||
| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK |
|
||||
|
||||
## Persistent installs
|
||||
|
||||
`headroom install` supports opencode as a target for persistent provider wiring:
|
||||
|
||||
```bash
|
||||
headroom install apply --preset persistent-service --providers manual --target opencode
|
||||
```
|
||||
|
||||
This writes the Headroom provider into `~/.config/opencode/opencode.json` and keeps the proxy running on port 8787.
|
||||
|
||||
Provider scope is also supported:
|
||||
|
||||
```bash
|
||||
headroom install apply --preset persistent-service --scope provider --providers manual --target opencode
|
||||
```
|
||||
|
||||
## How it works under the hood
|
||||
|
||||
1. **Config injection** — The wrap command writes a `provider.headroom` block into `opencode.json`. The provider uses the `@ai-sdk/openai-compatible` npm package, which OpenCode already supports natively. Model mappings route requests through `http://127.0.0.1:<port>/v1`.
|
||||
|
||||
2. **Runtime config** — `OPENCODE_CONFIG_CONTENT` is set as an env var containing the full provider + model + MCP JSON. OpenCode reads this at startup and merges it with the on-disk config.
|
||||
|
||||
3. **MCP tools** — Three Headroom MCP tools are registered: `headroom_compress` (compress context), `headroom_retrieve` (fetch original from CCR store), `headroom_stats` (compression statistics).
|
||||
|
||||
4. **Unwrap** — Restores `opencode.json` from the pre-wrap backup. If no backup exists, strips Headroom marker blocks from the config. Also unregisters Headroom MCP servers.
|
||||
|
||||
## Optional OpenCode plugin
|
||||
|
||||
The `headroom-opencode` npm package also exports an OpenCode plugin. The plugin
|
||||
is additive: it exposes the `headroom_retrieve` tool and Headroom metadata such
|
||||
as `HEADROOM_PROXY_URL`, but it does not route provider traffic by itself.
|
||||
|
||||
Provider traffic is still routed by the injected `headroom` provider in
|
||||
`OPENCODE_CONFIG_CONTENT` or `opencode.json`. This keeps providers configured
|
||||
through OpenCode `/connect` in charge of their own credentials and base URLs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**OpenCode doesn't use the headroom provider.**
|
||||
Check that `OPENCODE_CONFIG_CONTENT` is set and contains the correct provider block. The wrap command prints the env vars it sets.
|
||||
|
||||
**Provider not found after unwrap.**
|
||||
If unwrap left the provider configured, run `headroom unwrap opencode` again, or manually restore from `~/.config/opencode/opencode.json.headroom-backup`.
|
||||
|
||||
**Proxy port conflict.**
|
||||
Use `--port` to select a specific port, or let the proxy auto-select an available one.
|
||||
|
|
@ -91,6 +91,7 @@ Provider scope is intentionally conservative. The current direct adapters are:
|
|||
- Claude Code → `~/.claude/settings.json` `env`
|
||||
- Codex → managed block in `~/.codex/config.toml`
|
||||
- OpenClaw → existing `wrap openclaw` / `unwrap openclaw` flow
|
||||
- OpenCode → managed block in `~/.config/opencode/opencode.json`
|
||||
|
||||
For Copilot, Aider, Cursor, and broader env-driven setups, prefer `--scope user` or `--scope system`.
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||
AIDER_CHAT_VERSION=0.86.2 \
|
||||
CODEX_VERSION=0.118.0 \
|
||||
OPENCLAW_VERSION=2026.4.7 \
|
||||
OPENCODE_VERSION=1.17.8 \
|
||||
PATH="/opt/headroom-venv/bin:/opt/aider-venv/bin:${PATH}" \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
|
|
@ -108,7 +109,15 @@ RUN python -m venv /opt/headroom-venv && \
|
|||
python -m venv /opt/aider-venv && \
|
||||
/opt/aider-venv/bin/python -m pip install --upgrade pip && \
|
||||
/opt/aider-venv/bin/python -m pip install "aider-chat==${AIDER_CHAT_VERSION}" && \
|
||||
npm install -g --no-fund --no-audit "@openai/codex@${CODEX_VERSION}" "openclaw@${OPENCLAW_VERSION}"
|
||||
npm install -g --no-fund --no-audit "@openai/codex@${CODEX_VERSION}" "openclaw@${OPENCLAW_VERSION}" && \
|
||||
curl -fsSL https://opencode.ai/install | VERSION="${OPENCODE_VERSION}" bash
|
||||
|
||||
# The opencode installer adds its bin dir to .bashrc. Non-login shells
|
||||
# (including CMD, docker run, and e2e shims spawned by subprocess) won't
|
||||
# source .bashrc, so add the dir to PATH explicitly. The e2e shim shadows
|
||||
# opencode anyway, but this guarantees `which opencode` works for
|
||||
# diagnostics.
|
||||
ENV PATH="/root/.opencode/bin:${PATH}"
|
||||
|
||||
COPY e2e/wrap ./e2e/wrap
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ CLINE_PORT = 28892
|
|||
CONTINUE_PORT = 28893
|
||||
GOOSE_PORT = 28894
|
||||
OPENHANDS_PORT = 28895
|
||||
OPENCODE_PORT = 28896
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
|
|
@ -225,7 +226,12 @@ def create_shims(shim_dir: Path) -> None:
|
|||
"cwd": os.getcwd(),
|
||||
"env": {
|
||||
key: os.environ.get(key)
|
||||
for key in ("OPENAI_BASE_URL", "OPENAI_API_BASE", "ANTHROPIC_BASE_URL")
|
||||
for key in (
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
)
|
||||
if os.environ.get(key) is not None
|
||||
},
|
||||
}
|
||||
|
|
@ -276,7 +282,12 @@ def create_shims(shim_dir: Path) -> None:
|
|||
"cwd": os.getcwd(),
|
||||
"env": {
|
||||
key: os.environ.get(key)
|
||||
for key in ("OPENAI_BASE_URL", "OPENAI_API_BASE", "ANTHROPIC_BASE_URL")
|
||||
for key in (
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
)
|
||||
if os.environ.get(key) is not None
|
||||
},
|
||||
}
|
||||
|
|
@ -365,6 +376,7 @@ def create_shims(shim_dir: Path) -> None:
|
|||
write_executable(shim_dir / "claude", generic_shim)
|
||||
write_executable(shim_dir / "codex", codex_shim)
|
||||
write_executable(shim_dir / "aider", generic_shim)
|
||||
write_executable(shim_dir / "opencode", generic_shim)
|
||||
write_executable(shim_dir / "rtk", rtk_shim)
|
||||
|
||||
|
||||
|
|
@ -912,6 +924,7 @@ def main() -> None:
|
|||
verify_continue_wrap(base_env, project_dir)
|
||||
verify_goose_wrap(base_env, project_dir)
|
||||
verify_openhands_wrap(base_env, project_dir)
|
||||
verify_opencode_wrap(base_env, project_dir, log_dir)
|
||||
local_plugin_dir = prepare_local_openclaw_plugin(base_env, tmp_dir)
|
||||
verify_openclaw_wrap(base_env, project_dir, local_plugin_dir)
|
||||
finally:
|
||||
|
|
@ -921,5 +934,48 @@ def main() -> None:
|
|||
log("All Docker wrap e2e checks passed.")
|
||||
|
||||
|
||||
def verify_opencode_wrap(
|
||||
base_env: dict[str, str], project_dir: Path, log_dir: Path
|
||||
) -> None:
|
||||
port = OPENCODE_PORT
|
||||
run(
|
||||
["headroom", "wrap", "opencode", "--port", str(port), "--", "--help"],
|
||||
env=base_env,
|
||||
cwd=project_dir,
|
||||
timeout=120,
|
||||
)
|
||||
global_agents = Path(base_env["HOME"]) / ".config" / "opencode" / "AGENTS.md"
|
||||
project_agents = project_dir / "AGENTS.md"
|
||||
assert_true(global_agents.exists(), "Opencode wrap should create ~/.config/opencode/AGENTS.md")
|
||||
assert_true(project_agents.exists(), "Opencode wrap should create project AGENTS.md")
|
||||
assert_true(RTK_MARKER in global_agents.read_text(encoding="utf-8"), "Missing RTK marker in global AGENTS.md")
|
||||
assert_true(
|
||||
RTK_MARKER in project_agents.read_text(encoding="utf-8"),
|
||||
"Missing RTK marker in project AGENTS.md",
|
||||
)
|
||||
|
||||
entries = read_jsonl(log_dir / "opencode.jsonl")
|
||||
assert_true(len(entries) > 0, "Opencode shim should have been invoked")
|
||||
env_vars = entries[-1]["env"]
|
||||
assert_true(
|
||||
env_vars.get("OPENCODE_CONFIG_CONTENT") is not None,
|
||||
"Opencode wrap should set OPENCODE_CONFIG_CONTENT",
|
||||
)
|
||||
config = json.loads(env_vars["OPENCODE_CONFIG_CONTENT"])
|
||||
assert_true(
|
||||
config["provider"]["headroom"]["options"]["baseURL"] == f"http://127.0.0.1:{port}/v1",
|
||||
"Opencode wrap should inject headroom provider baseURL",
|
||||
)
|
||||
|
||||
run(["headroom", "unwrap", "opencode", "--port", str(port)], env=base_env, cwd=project_dir, timeout=120)
|
||||
config_path = Path(base_env["HOME"]) / ".config" / "opencode" / "opencode.json"
|
||||
if config_path.exists():
|
||||
content = config_path.read_text(encoding="utf-8")
|
||||
assert_true(
|
||||
"headroom" not in content,
|
||||
"Opencode unwrap should remove headroom provider from config",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -143,6 +143,24 @@ def detect_platform() -> PlatformKey:
|
|||
# ---------- Cache dir ----------------------------------------------------- #
|
||||
|
||||
|
||||
def _is_writable_dir(path: Path) -> bool:
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except OSError:
|
||||
return False
|
||||
return path.is_dir() and bool(mode & 0o222)
|
||||
|
||||
|
||||
def _has_writable_existing_parent(path: Path) -> bool:
|
||||
current = path
|
||||
while not current.exists():
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return False
|
||||
current = parent
|
||||
return _is_writable_dir(current)
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
override = os.environ.get("HEADROOM_BINARIES_CACHE")
|
||||
if override:
|
||||
|
|
@ -218,7 +236,11 @@ def _mirror_url(url: str) -> str:
|
|||
def _download(url: str, dest: Path, *, progress: bool = True) -> None:
|
||||
if os.environ.get("HEADROOM_BINARIES_OFFLINE"):
|
||||
raise OfflineError(f"offline mode (HEADROOM_BINARIES_OFFLINE=1) but fetch required: {url}")
|
||||
if not _has_writable_existing_parent(dest.parent):
|
||||
raise OSError(f"binary cache directory parent is not writable: {dest.parent}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not _is_writable_dir(dest.parent):
|
||||
raise OSError(f"binary cache directory is not writable: {dest.parent}")
|
||||
final_url = _mirror_url(url)
|
||||
req = urllib.request.Request(final_url, headers={"User-Agent": "headroom-binaries/1"})
|
||||
try:
|
||||
|
|
@ -287,7 +309,11 @@ def _verify_sha256(path: Path, expected: str | None) -> None:
|
|||
|
||||
def _extract(archive: Path, member: str, dest: Path) -> None:
|
||||
"""Extract `member` from archive into `dest` (single-file binary)."""
|
||||
if not _has_writable_existing_parent(dest.parent):
|
||||
raise OSError(f"binary cache directory parent is not writable: {dest.parent}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not _is_writable_dir(dest.parent):
|
||||
raise OSError(f"binary cache directory is not writable: {dest.parent}")
|
||||
name = archive.name.lower()
|
||||
try:
|
||||
if name.endswith(".tar.gz") or name.endswith(".tgz"):
|
||||
|
|
@ -452,6 +478,8 @@ def resolve(tool: str) -> Path:
|
|||
|
||||
|
||||
def ensure_tools(quiet: bool = False) -> dict[str, Path | None]:
|
||||
if not _has_writable_existing_parent(cache_dir()):
|
||||
return {name: which(name) for name in _registry().get("tools", {})}
|
||||
"""Install every tool in the registry if missing. Safe to call repeatedly.
|
||||
|
||||
Called at proxy startup and on first `headroom` CLI invocation so that no
|
||||
|
|
|
|||
|
|
@ -107,6 +107,17 @@ from headroom.providers.openclaw import (
|
|||
from headroom.providers.openclaw import (
|
||||
normalize_gateway_provider_ids as _normalize_openclaw_gateway_provider_ids_impl,
|
||||
)
|
||||
from headroom.providers.opencode import build_launch_env as _build_opencode_launch_env
|
||||
from headroom.providers.opencode.config import (
|
||||
_MCP_MARKER_END, # noqa: F401
|
||||
_MCP_MARKER_START,
|
||||
_PROVIDER_MARKER_END, # noqa: F401
|
||||
_PROVIDER_MARKER_START,
|
||||
inject_opencode_provider_config,
|
||||
opencode_config_paths,
|
||||
snapshot_opencode_config_if_unwrapped,
|
||||
strip_opencode_headroom_blocks,
|
||||
)
|
||||
from headroom.proxy.project_context import with_project_prefix as _with_project_prefix
|
||||
|
||||
from .main import main
|
||||
|
|
@ -115,7 +126,7 @@ _CONTEXT_TOOL_ENV = "HEADROOM_CONTEXT_TOOL"
|
|||
_CONTEXT_TOOL_RTK = "rtk"
|
||||
_CONTEXT_TOOL_LEAN_CTX = "lean-ctx"
|
||||
_VALID_CONTEXT_TOOLS = {_CONTEXT_TOOL_RTK, _CONTEXT_TOOL_LEAN_CTX}
|
||||
_AGENT_SAVINGS_TARGET_AGENTS = {"claude", "codex", "cursor"}
|
||||
_AGENT_SAVINGS_TARGET_AGENTS = {"claude", "codex", "cursor", "opencode"}
|
||||
_WRAP_PROXY_TIMEOUT_ENV = "HEADROOM_WRAP_PROXY_TIMEOUT"
|
||||
_WRAP_PROXY_TIMEOUT_DEFAULT_SECONDS = 45
|
||||
_WRAP_PROXY_TIMEOUT_ML_DEFAULT_SECONDS = 90
|
||||
|
|
@ -2910,6 +2921,7 @@ def wrap() -> None:
|
|||
headroom wrap goose # Goose (Block) CLI
|
||||
headroom wrap openhands # OpenHands CLI
|
||||
headroom wrap openclaw # OpenClaw plugin bootstrap
|
||||
headroom wrap opencode # OpenCode CLI
|
||||
|
||||
\b
|
||||
`wrap` vs `proxy`:
|
||||
|
|
@ -2920,8 +2932,6 @@ def wrap() -> None:
|
|||
ANTHROPIC_BASE_URL / OPENAI_BASE_URL yourself.
|
||||
|
||||
\b
|
||||
Note: `headroom wrap opencode` does NOT exist. For opencode, run
|
||||
`headroom proxy` and point opencode at it via OPENAI_BASE_URL.
|
||||
`openclaw` is a separate tool — different from opencode.
|
||||
"""
|
||||
|
||||
|
|
@ -4778,6 +4788,251 @@ def openclaw(
|
|||
click.echo()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OpenCode
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@wrap.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
|
||||
@click.option(
|
||||
"--no-context-tool",
|
||||
"--no-rtk",
|
||||
"no_rtk",
|
||||
is_flag=True,
|
||||
help="Skip CLI context-tool setup",
|
||||
)
|
||||
@click.option("--no-mcp", is_flag=True, help="Skip headroom MCP server registration")
|
||||
@click.option("--no-serena", is_flag=True, help="Skip Serena MCP server registration")
|
||||
@click.option(
|
||||
"--code-graph",
|
||||
is_flag=True,
|
||||
help="Enable code graph indexing via codebase-memory-mcp (optional)",
|
||||
)
|
||||
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
|
||||
@click.option("--learn", is_flag=True, help="Enable live traffic learning")
|
||||
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
|
||||
@click.option(
|
||||
"--backend", default=None, help="API backend: 'anthropic', 'anyllm', 'litellm-vertex', etc."
|
||||
)
|
||||
@click.option("--anyllm-provider", default=None, help="Provider for any-llm backend")
|
||||
@click.option("--region", default=None, help="Cloud region for Bedrock/Vertex")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
||||
@click.option("--prepare-only", is_flag=True, hidden=True)
|
||||
@click.argument("opencode_args", nargs=-1, type=click.UNPROCESSED)
|
||||
def opencode(
|
||||
port: int,
|
||||
no_rtk: bool,
|
||||
no_mcp: bool,
|
||||
no_serena: bool,
|
||||
code_graph: bool,
|
||||
no_proxy: bool,
|
||||
learn: bool,
|
||||
memory: bool,
|
||||
backend: str | None,
|
||||
anyllm_provider: str | None,
|
||||
region: str | None,
|
||||
verbose: bool,
|
||||
prepare_only: bool,
|
||||
opencode_args: tuple,
|
||||
) -> None:
|
||||
"""Launch OpenCode through Headroom proxy.
|
||||
|
||||
\b
|
||||
Sets OPENCODE_CONFIG_CONTENT to route all OpenCode API calls through
|
||||
Headroom. Configures a headroom provider via @ai-sdk/openai-compatible.
|
||||
Also sets OPENAI_BASE_URL and ANTHROPIC_BASE_URL as fallbacks.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
headroom wrap opencode # Start proxy + context tool + opencode
|
||||
headroom wrap opencode -- "fix the bug" # Pass prompt to opencode
|
||||
headroom wrap opencode --no-context-tool # Skip CLI context-tool setup
|
||||
headroom wrap opencode --no-mcp # Skip MCP retrieve tool registration
|
||||
headroom wrap opencode --no-serena # Skip Serena MCP registration
|
||||
headroom wrap opencode --port 9999 # Custom proxy port
|
||||
headroom wrap opencode --backend anyllm --anyllm-provider groq
|
||||
"""
|
||||
# Snapshot OpenCode config.json BEFORE any wrap-time mutation so
|
||||
# `headroom unwrap opencode` can restore the user's pre-wrap state.
|
||||
_opencode_config_file, _opencode_backup_file = opencode_config_paths()
|
||||
snapshot_opencode_config_if_unwrapped(_opencode_config_file, _opencode_backup_file)
|
||||
|
||||
# Setup CLI context tool for OpenCode.
|
||||
if not no_rtk:
|
||||
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
|
||||
click.echo(" Setting up lean-ctx for OpenCode...")
|
||||
_setup_lean_ctx_agent("opencode", verbose=verbose)
|
||||
else:
|
||||
click.echo(" Setting up rtk for OpenCode...")
|
||||
rtk_path = _ensure_rtk_binary(verbose=verbose)
|
||||
if rtk_path:
|
||||
# Inject into project AGENTS.md
|
||||
project_agents = Path.cwd() / "AGENTS.md"
|
||||
_inject_rtk_instructions(project_agents, verbose=verbose)
|
||||
# Inject into global OpenCode AGENTS.md
|
||||
global_agents = _opencode_home_dir() / "AGENTS.md"
|
||||
_inject_rtk_instructions(global_agents, verbose=verbose)
|
||||
|
||||
# Register headroom MCP server in OpenCode config so OpenCode can
|
||||
# call headroom_retrieve on compression markers from the proxy.
|
||||
if not no_mcp:
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
_setup_headroom_mcp(OpencodeRegistrar(), port, verbose=verbose, force=True)
|
||||
elif verbose:
|
||||
click.echo(" Skipping MCP retrieve tool (--no-mcp)")
|
||||
|
||||
if not no_serena:
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
_setup_serena_mcp(OpencodeRegistrar(), context="opencode", verbose=verbose, force=True)
|
||||
else:
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
_disable_serena_mcp(OpencodeRegistrar(), verbose=verbose)
|
||||
|
||||
# Setup memory MCP server for OpenCode (native tool integration)
|
||||
if memory:
|
||||
click.echo(" Setting up memory for OpenCode...")
|
||||
mem_dir = Path.cwd() / ".headroom"
|
||||
mem_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = str(mem_dir / "memory.db")
|
||||
mem_user = os.environ.get("USER", os.environ.get("USERNAME", "default"))
|
||||
_inject_memory_mcp_config(db_path, mem_user)
|
||||
agents_md = Path.cwd() / "AGENTS.md"
|
||||
_inject_memory_agents_md(agents_md)
|
||||
|
||||
if prepare_only:
|
||||
inject_opencode_provider_config(port)
|
||||
return
|
||||
|
||||
opencode_bin = shutil.which("opencode")
|
||||
if not opencode_bin:
|
||||
click.echo("Error: 'opencode' not found in PATH.")
|
||||
click.echo("Install OpenCode: https://opencode.ai")
|
||||
raise SystemExit(1)
|
||||
|
||||
env, env_vars_display = _build_opencode_launch_env(
|
||||
port, os.environ, project=_project_name_from_cwd(), include_mcp=not no_mcp
|
||||
)
|
||||
|
||||
# Inject Headroom provider into OpenCode config so traffic routes through proxy.
|
||||
inject_opencode_provider_config(port)
|
||||
if memory:
|
||||
mem_dir = Path.cwd() / ".headroom"
|
||||
_inject_memory_mcp_config(
|
||||
str(mem_dir / "memory.db"),
|
||||
os.environ.get("USER", os.environ.get("USERNAME", "default")),
|
||||
)
|
||||
|
||||
_launch_tool(
|
||||
binary=opencode_bin,
|
||||
args=opencode_args,
|
||||
env=env,
|
||||
port=port,
|
||||
no_proxy=no_proxy,
|
||||
tool_label="OPENCODE",
|
||||
env_vars_display=env_vars_display,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="opencode",
|
||||
code_graph=code_graph,
|
||||
backend=backend,
|
||||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
)
|
||||
|
||||
|
||||
def _opencode_home_dir() -> Path:
|
||||
"""Return the OpenCode home/config directory."""
|
||||
env_path = os.environ.get("OPENCODE_HOME", "").strip()
|
||||
if env_path:
|
||||
return Path(env_path).expanduser()
|
||||
return Path.home() / ".config" / "opencode"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OpenCode (unwrap)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@unwrap.command("opencode")
|
||||
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
|
||||
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
|
||||
def unwrap_opencode(port: int, no_stop_proxy: bool) -> None:
|
||||
"""Undo ``headroom wrap opencode`` edits to the active OpenCode config file.
|
||||
|
||||
Behaviour:
|
||||
|
||||
* If a pre-wrap backup (``opencode.json.headroom-backup``) exists, the
|
||||
original file is restored byte-for-byte and the backup is removed.
|
||||
* Otherwise, if the config file still contains the Headroom-managed
|
||||
block, that block is stripped out and the rest of the file is
|
||||
preserved.
|
||||
* If the config only ever contained Headroom-written content, the file
|
||||
is removed entirely so OpenCode falls back to its defaults.
|
||||
* If neither a backup nor a Headroom block is present, this is a safe
|
||||
no-op.
|
||||
"""
|
||||
click.echo()
|
||||
click.echo(" ╔═══════════════════════════════════════════════╗")
|
||||
click.echo(" ║ HEADROOM UNWRAP: OPENCODE ║")
|
||||
click.echo(" ╚═══════════════════════════════════════════════╝")
|
||||
click.echo()
|
||||
|
||||
config_file, backup_file = opencode_config_paths()
|
||||
|
||||
if backup_file.exists():
|
||||
try:
|
||||
shutil.copy2(backup_file, config_file)
|
||||
backup_file.unlink()
|
||||
click.echo(f" Restored prior {config_file} from pre-wrap backup.")
|
||||
status = "restored"
|
||||
except OSError as exc:
|
||||
raise click.ClickException(
|
||||
f"could not restore OpenCode config from backup: {exc}"
|
||||
) from exc
|
||||
elif config_file.exists():
|
||||
content = config_file.read_text()
|
||||
if _PROVIDER_MARKER_START in content or _MCP_MARKER_START in content:
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
if cleaned.strip():
|
||||
config_file.write_text(cleaned + "\n", encoding="utf-8")
|
||||
click.echo(f" Removed Headroom block from {config_file}; other content preserved.")
|
||||
status = "cleaned"
|
||||
else:
|
||||
config_file.unlink()
|
||||
click.echo(f" Removed {config_file} (contained only Headroom-written config).")
|
||||
status = "removed"
|
||||
else:
|
||||
click.echo(f" Nothing to undo: {config_file} has no Headroom wrap markers.")
|
||||
status = "noop"
|
||||
else:
|
||||
click.echo(f" Nothing to undo: {config_file} does not exist.")
|
||||
status = "noop"
|
||||
|
||||
# Remove Serena MCP if it was installed by Headroom.
|
||||
# Also remove the headroom MCP server itself.
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
opencode_registrar = OpencodeRegistrar()
|
||||
if opencode_registrar.detect():
|
||||
if opencode_registrar.unregister_server("headroom"):
|
||||
click.echo(" Removed Headroom MCP server from OpenCode.")
|
||||
serena_status = _remove_headroom_installed_serena_mcp(opencode_registrar)
|
||||
if serena_status == "removed":
|
||||
click.echo(" Removed Headroom-installed Serena MCP server from OpenCode.")
|
||||
elif serena_status == "failed":
|
||||
click.echo(" Serena MCP server matched Headroom ledger but could not be removed.")
|
||||
|
||||
click.echo()
|
||||
click.echo("✓ OpenCode is no longer routed through the Headroom proxy.")
|
||||
if not no_stop_proxy and status != "noop":
|
||||
_echo_unwrap_proxy_stop_status(_stop_local_proxy_for_unwrap(port), port)
|
||||
click.echo()
|
||||
|
||||
|
||||
@unwrap.command("openclaw")
|
||||
@click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
|
||||
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class ToolTarget(str, Enum):
|
|||
AIDER = "aider"
|
||||
CURSOR = "cursor"
|
||||
OPENCLAW = "openclaw"
|
||||
OPENCODE = "opencode"
|
||||
|
||||
|
||||
def iso_utc_now() -> str:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -118,3 +119,16 @@ def openclaw_config_path() -> Path:
|
|||
"""Return the OpenClaw config path."""
|
||||
|
||||
return Path.home() / ".openclaw" / "openclaw.json"
|
||||
|
||||
|
||||
def opencode_config_path() -> Path:
|
||||
"""Return the OpenCode config path.
|
||||
|
||||
Resolves ``~/.config/opencode/opencode.json`` when ``OPENCODE_CONFIG``
|
||||
is unset; otherwise the value of that environment variable.
|
||||
"""
|
||||
|
||||
env_path = os.environ.get("OPENCODE_CONFIG", "").strip()
|
||||
if env_path:
|
||||
return Path(env_path).expanduser()
|
||||
return Path.home() / ".config" / "opencode" / "opencode.json"
|
||||
|
|
|
|||
|
|
@ -27,11 +27,13 @@ SUPPORTED_TARGETS = [
|
|||
ToolTarget.AIDER,
|
||||
ToolTarget.CURSOR,
|
||||
ToolTarget.OPENCLAW,
|
||||
ToolTarget.OPENCODE,
|
||||
]
|
||||
PROVIDER_SCOPE_TARGETS = [
|
||||
ToolTarget.CLAUDE,
|
||||
ToolTarget.CODEX,
|
||||
ToolTarget.OPENCLAW,
|
||||
ToolTarget.OPENCODE,
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -72,7 +74,7 @@ def resolve_targets(
|
|||
if unsupported:
|
||||
unsupported_list = ", ".join(sorted(set(unsupported)))
|
||||
raise click.ClickException(
|
||||
"Provider scope supports only claude, codex, and openclaw; "
|
||||
"Provider scope supports only claude, codex, openclaw, and opencode; "
|
||||
f"unsupported targets: {unsupported_list}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
|
|||
|
||||
|
||||
def _ensure_host_dirs() -> None:
|
||||
for subdir in (".headroom", ".claude", ".codex", ".gemini"):
|
||||
for subdir in (".headroom", ".claude", ".codex", ".gemini", ".config/opencode"):
|
||||
(Path.home() / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
|
|
@ -120,6 +120,8 @@ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
|
|||
f"{_mount_source(home, '.codex')}:{container_home}/.codex",
|
||||
"--volume",
|
||||
f"{_mount_source(home, '.gemini')}:{container_home}/.gemini",
|
||||
"--volume",
|
||||
f"{_mount_source(home, '.config/opencode')}:{container_home}/.config/opencode",
|
||||
]
|
||||
if not _is_windows():
|
||||
getuid = getattr(os, "getuid", None)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from .base import MCPRegistrar, RegisterResult, RegisterStatus, ServerSpec
|
|||
from .claude import ClaudeRegistrar
|
||||
from .codex import CodexRegistrar
|
||||
from .display import any_succeeded, format_result, format_results
|
||||
from .opencode import OpencodeRegistrar
|
||||
from .install import (
|
||||
DEFAULT_PROXY_URL,
|
||||
build_headroom_spec,
|
||||
|
|
@ -30,6 +31,7 @@ __all__ = [
|
|||
"ClaudeRegistrar",
|
||||
"CodexRegistrar",
|
||||
"MCPRegistrar",
|
||||
"OpencodeRegistrar",
|
||||
"RegisterResult",
|
||||
"RegisterStatus",
|
||||
"ServerSpec",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from collections.abc import Iterable
|
|||
from .base import MCPRegistrar, RegisterResult, RegisterStatus, ServerSpec
|
||||
from .claude import ClaudeRegistrar
|
||||
from .codex import CodexRegistrar
|
||||
from .opencode import OpencodeRegistrar
|
||||
|
||||
#: Default proxy URL used when none is given.
|
||||
DEFAULT_PROXY_URL = "http://127.0.0.1:8787"
|
||||
|
|
@ -17,7 +18,7 @@ def get_all_registrars() -> list[MCPRegistrar]:
|
|||
|
||||
The list grows as we add adapters for Cursor, Continue, Cline, etc.
|
||||
"""
|
||||
return [ClaudeRegistrar(), CodexRegistrar()]
|
||||
return [ClaudeRegistrar(), CodexRegistrar(), OpencodeRegistrar()]
|
||||
|
||||
|
||||
def build_headroom_spec(proxy_url: str = DEFAULT_PROXY_URL) -> ServerSpec:
|
||||
|
|
|
|||
182
headroom/mcp_registry/opencode.py
Normal file
182
headroom/mcp_registry/opencode.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""OpenCode MCP registrar.
|
||||
|
||||
OpenCode stores MCP server configuration in ``~/.config/opencode/opencode.json``
|
||||
under the top-level ``mcp`` key. This registrar edits that JSON file directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import MCPRegistrar, RegisterResult, RegisterStatus, ServerSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _opencode_home_dir() -> Path:
|
||||
"""Return the OpenCode home/config directory."""
|
||||
env_path = os.environ.get("OPENCODE_HOME", "").strip()
|
||||
if env_path:
|
||||
return Path(env_path).expanduser()
|
||||
return Path.home() / ".config" / "opencode"
|
||||
|
||||
|
||||
def _opencode_config_path() -> Path:
|
||||
"""Return the active OpenCode config path."""
|
||||
env_path = os.environ.get("OPENCODE_CONFIG", "").strip()
|
||||
if env_path:
|
||||
return Path(env_path).expanduser()
|
||||
return _opencode_home_dir() / "opencode.json"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
"""Read a JSON file, returning empty dict if absent or unparseable."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _entry_to_spec(name: str, entry: dict[str, Any]) -> ServerSpec:
|
||||
command_value = entry.get("command")
|
||||
if isinstance(command_value, list):
|
||||
args = tuple(str(x) for x in command_value[1:])
|
||||
command = str(command_value[0])
|
||||
else:
|
||||
command = str(command_value) if command_value else ""
|
||||
args = ()
|
||||
env_value = entry.get("env", {})
|
||||
env: dict[str, str] = {}
|
||||
if isinstance(env_value, dict):
|
||||
env = {str(k): str(v) for k, v in env_value.items()}
|
||||
return ServerSpec(name=name, command=command, args=args, env=env)
|
||||
|
||||
|
||||
def _spec_to_entry(spec: ServerSpec) -> dict[str, Any]:
|
||||
entry: dict[str, Any] = {
|
||||
"type": "remote",
|
||||
"url": "",
|
||||
"enabled": True,
|
||||
}
|
||||
if spec.args:
|
||||
entry["command"] = [spec.command, *spec.args]
|
||||
else:
|
||||
entry["command"] = spec.command
|
||||
if spec.env:
|
||||
entry["env"] = dict(spec.env)
|
||||
return entry
|
||||
|
||||
|
||||
def _specs_equivalent(a: ServerSpec, b: ServerSpec) -> bool:
|
||||
return (
|
||||
a.name == b.name
|
||||
and a.command == b.command
|
||||
and tuple(a.args) == tuple(b.args)
|
||||
and dict(a.env) == dict(b.env)
|
||||
)
|
||||
|
||||
|
||||
def _diff_specs(existing: ServerSpec, requested: ServerSpec) -> str:
|
||||
parts: list[str] = []
|
||||
if existing.command != requested.command:
|
||||
parts.append(f"command {existing.command!r} -> {requested.command!r}")
|
||||
if tuple(existing.args) != tuple(requested.args):
|
||||
parts.append(f"args {list(existing.args)} -> {list(requested.args)}")
|
||||
if dict(existing.env) != dict(requested.env):
|
||||
parts.append(f"env {dict(existing.env)} -> {dict(requested.env)}")
|
||||
if not parts:
|
||||
return "spec differs in unidentified field(s)"
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
class OpencodeRegistrar(MCPRegistrar):
|
||||
"""Register MCP servers with OpenCode."""
|
||||
|
||||
name = "opencode"
|
||||
display_name = "OpenCode"
|
||||
|
||||
def __init__(self, *, config_path: Path | None = None) -> None:
|
||||
self._config_path = config_path or _opencode_config_path()
|
||||
|
||||
def detect(self) -> bool:
|
||||
if shutil.which("opencode"):
|
||||
return True
|
||||
return self._config_path.parent.is_dir()
|
||||
|
||||
def get_server(self, server_name: str) -> ServerSpec | None:
|
||||
data = _read_json(self._config_path)
|
||||
mcp = data.get("mcp", {})
|
||||
if not isinstance(mcp, dict):
|
||||
return None
|
||||
entry = mcp.get(server_name)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
return _entry_to_spec(server_name, entry)
|
||||
|
||||
def register_server(self, spec: ServerSpec, *, force: bool = False) -> RegisterResult:
|
||||
existing = self.get_server(spec.name)
|
||||
|
||||
if existing is not None and _specs_equivalent(existing, spec):
|
||||
return RegisterResult(RegisterStatus.ALREADY, "matches current configuration")
|
||||
|
||||
if existing is not None and not force:
|
||||
return RegisterResult(
|
||||
RegisterStatus.MISMATCH,
|
||||
_diff_specs(existing, spec),
|
||||
)
|
||||
|
||||
if existing is not None and force:
|
||||
# Remove the existing entry before rewriting.
|
||||
self.unregister_server(spec.name)
|
||||
|
||||
return self._write_entry(spec)
|
||||
|
||||
def unregister_server(self, server_name: str) -> bool:
|
||||
data = _read_json(self._config_path)
|
||||
mcp = data.get("mcp", {})
|
||||
if not isinstance(mcp, dict):
|
||||
return False
|
||||
if server_name not in mcp:
|
||||
return False
|
||||
del mcp[server_name]
|
||||
if not mcp:
|
||||
data.pop("mcp", None)
|
||||
try:
|
||||
_write_json(self._config_path, data)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _write_entry(self, spec: ServerSpec) -> RegisterResult:
|
||||
try:
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = _read_json(self._config_path)
|
||||
mcp = data.setdefault("mcp", {})
|
||||
if not isinstance(mcp, dict):
|
||||
mcp = {}
|
||||
data["mcp"] = mcp
|
||||
mcp[spec.name] = _spec_to_entry(spec)
|
||||
_write_json(self._config_path, data)
|
||||
except OSError as exc:
|
||||
return RegisterResult(
|
||||
RegisterStatus.FAILED, f"could not write {self._config_path}: {exc}"
|
||||
)
|
||||
return RegisterResult(RegisterStatus.REGISTERED, f"wrote to {self._config_path}")
|
||||
|
|
@ -45,7 +45,7 @@ FLUSH_DEBOUNCE_SECONDS = 10.0
|
|||
|
||||
# Absolute file-path heuristic for anchoring a pattern to a project root.
|
||||
# Matches POSIX paths (starts with /) and common Windows drive paths.
|
||||
_ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\\-]+")
|
||||
_ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\@\-]+")
|
||||
|
||||
# Error-recovery refinement: the Learned: error recovery section is capped,
|
||||
# decayed, and re-validated at render time. Other categories are untouched.
|
||||
|
|
|
|||
|
|
@ -549,16 +549,9 @@ def configure_otel_metrics(config: OTelMetricsConfig | None = None) -> HeadroomO
|
|||
|
||||
def get_otel_metrics_status() -> dict[str, Any]:
|
||||
with _metrics_lock:
|
||||
if _owned_metrics_config is None:
|
||||
return {
|
||||
"configured": False,
|
||||
"enabled": False,
|
||||
"service_name": None,
|
||||
"exporter": None,
|
||||
"endpoint": None,
|
||||
"resource_attributes": {},
|
||||
}
|
||||
return _owned_metrics_config.status()
|
||||
if _owned_metrics_config is not None:
|
||||
return _owned_metrics_config.status()
|
||||
return OTelMetricsConfig.from_env(default_service_name="headroom-proxy").status()
|
||||
|
||||
|
||||
def shutdown_otel_metrics() -> None:
|
||||
|
|
|
|||
|
|
@ -194,15 +194,25 @@ def configure_langfuse_tracing(
|
|||
|
||||
def get_langfuse_tracing_status() -> dict[str, Any]:
|
||||
with _tracing_lock:
|
||||
if _owned_langfuse_config is None:
|
||||
return {
|
||||
"configured": False,
|
||||
"enabled": False,
|
||||
"service_name": None,
|
||||
"base_url": None,
|
||||
"endpoint": None,
|
||||
}
|
||||
return _owned_langfuse_config.status()
|
||||
if _owned_langfuse_config is not None:
|
||||
return _owned_langfuse_config.status()
|
||||
if not any(
|
||||
os.environ.get(name)
|
||||
for name in (
|
||||
"HEADROOM_LANGFUSE_ENABLED",
|
||||
"LANGFUSE_PUBLIC_KEY",
|
||||
"LANGFUSE_SECRET_KEY",
|
||||
"LANGFUSE_BASE_URL",
|
||||
)
|
||||
):
|
||||
return {
|
||||
"configured": False,
|
||||
"enabled": False,
|
||||
"service_name": None,
|
||||
"base_url": None,
|
||||
"endpoint": None,
|
||||
}
|
||||
return LangfuseTracingConfig.from_env(default_service_name="headroom-proxy").status()
|
||||
|
||||
|
||||
def shutdown_headroom_tracing() -> None:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ from headroom.providers.openclaw.install import (
|
|||
from headroom.providers.openclaw.install import (
|
||||
revert_provider_scope as _revert_openclaw_provider_scope,
|
||||
)
|
||||
from headroom.providers.opencode.install import (
|
||||
apply_provider_scope as _apply_opencode_provider_scope,
|
||||
)
|
||||
from headroom.providers.opencode.install import build_install_env as _build_opencode_install_env
|
||||
from headroom.providers.opencode.install import (
|
||||
revert_provider_scope as _revert_opencode_provider_scope,
|
||||
)
|
||||
|
||||
_InstallEnvBuilder = Callable[..., dict[str, str]]
|
||||
_ProviderScopeApplier = Callable[[DeploymentManifest], ManagedMutation | None]
|
||||
|
|
@ -47,12 +54,14 @@ _ENV_BUILDERS: dict[str, _InstallEnvBuilder] = {
|
|||
"aider": _build_aider_install_env,
|
||||
"cortex-code": _build_cortex_code_install_env,
|
||||
"cursor": _build_cursor_install_env,
|
||||
"opencode": _build_opencode_install_env,
|
||||
}
|
||||
|
||||
_PROVIDER_SCOPE_HANDLERS: dict[str, tuple[_ProviderScopeApplier, _ProviderScopeReverter]] = {
|
||||
"claude": (_apply_claude_provider_scope, _revert_claude_provider_scope),
|
||||
"codex": (_apply_codex_provider_scope, _revert_codex_provider_scope),
|
||||
"openclaw": (_apply_openclaw_provider_scope, _revert_openclaw_provider_scope),
|
||||
"opencode": (_apply_opencode_provider_scope, _revert_opencode_provider_scope),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
31
headroom/providers/opencode/__init__.py
Normal file
31
headroom/providers/opencode/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""OpenCode-specific provider helpers."""
|
||||
|
||||
from .config import (
|
||||
_MCP_MARKER_END,
|
||||
_MCP_MARKER_START,
|
||||
_PROVIDER_MARKER_END,
|
||||
_PROVIDER_MARKER_START,
|
||||
inject_opencode_provider_config,
|
||||
opencode_config_paths,
|
||||
snapshot_opencode_config_if_unwrapped,
|
||||
strip_opencode_headroom_blocks,
|
||||
)
|
||||
from .install import apply_provider_scope, build_install_env, revert_provider_scope
|
||||
from .runtime import build_launch_env, build_opencode_config_content, proxy_base_url
|
||||
|
||||
__all__ = [
|
||||
"_MCP_MARKER_END",
|
||||
"_MCP_MARKER_START",
|
||||
"_PROVIDER_MARKER_END",
|
||||
"_PROVIDER_MARKER_START",
|
||||
"apply_provider_scope",
|
||||
"build_install_env",
|
||||
"build_launch_env",
|
||||
"build_opencode_config_content",
|
||||
"inject_opencode_provider_config",
|
||||
"opencode_config_paths",
|
||||
"proxy_base_url",
|
||||
"revert_provider_scope",
|
||||
"snapshot_opencode_config_if_unwrapped",
|
||||
"strip_opencode_headroom_blocks",
|
||||
]
|
||||
229
headroom/providers/opencode/config.py
Normal file
229
headroom/providers/opencode/config.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""OpenCode config file helpers for wrap and persistent install."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from headroom.install.paths import opencode_config_path
|
||||
|
||||
# Headroom-managed JSON marker comments for idempotent block injection.
|
||||
_PROVIDER_MARKER_START = "// --- Headroom proxy provider ---"
|
||||
_PROVIDER_MARKER_END = "// --- end Headroom proxy provider ---"
|
||||
_MCP_MARKER_START = "// --- Headroom MCP server ---"
|
||||
_MCP_MARKER_END = "// --- end Headroom MCP server ---"
|
||||
|
||||
# Regex to strip headroom blocks (including the marker comments).
|
||||
_PROVIDER_BLOCK_RE = re.compile(
|
||||
re.escape(_PROVIDER_MARKER_START)
|
||||
+ r".*?"
|
||||
+ re.escape(_PROVIDER_MARKER_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
_MCP_BLOCK_RE = re.compile(
|
||||
re.escape(_MCP_MARKER_START)
|
||||
+ r".*?"
|
||||
+ re.escape(_MCP_MARKER_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
HEADROOM_OPENCODE_PLUGIN = "headroom-opencode"
|
||||
|
||||
|
||||
def _opencode_home_dir() -> Path:
|
||||
"""Return the OpenCode home/config directory."""
|
||||
env_path = os.environ.get("OPENCODE_HOME", "").strip()
|
||||
if env_path:
|
||||
return Path(env_path).expanduser()
|
||||
return Path.home() / ".config" / "opencode"
|
||||
|
||||
|
||||
def opencode_config_paths() -> tuple[Path, Path]:
|
||||
"""Return ``(config_file, backup_file)`` for OpenCode."""
|
||||
config_file = opencode_config_path()
|
||||
backup_file = config_file.with_suffix(".json.headroom-backup")
|
||||
return config_file, backup_file
|
||||
|
||||
|
||||
def snapshot_opencode_config_if_unwrapped(config_file: Path, backup_file: Path) -> None:
|
||||
"""Snapshot ``opencode.json`` to ``backup_file`` before the first injection.
|
||||
|
||||
Guarantees that ``headroom unwrap opencode`` can restore the user's
|
||||
original file byte-for-byte.
|
||||
"""
|
||||
if backup_file.exists():
|
||||
return
|
||||
if not config_file.exists():
|
||||
return
|
||||
try:
|
||||
content = config_file.read_text()
|
||||
except OSError:
|
||||
return
|
||||
if _PROVIDER_MARKER_START in content or _MCP_MARKER_START in content:
|
||||
return
|
||||
backup_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(config_file, backup_file)
|
||||
|
||||
|
||||
def strip_opencode_headroom_blocks(content: str, *, remove_mcp: bool = True) -> str:
|
||||
"""Remove all Headroom-managed blocks from opencode JSON text.
|
||||
|
||||
Preserves user content. Returns the cleaned string.
|
||||
"""
|
||||
content = _PROVIDER_BLOCK_RE.sub("", content)
|
||||
if remove_mcp:
|
||||
content = _MCP_BLOCK_RE.sub("", content)
|
||||
# Collapse multiple blank lines left behind by block removal.
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
return content.strip()
|
||||
|
||||
|
||||
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"},
|
||||
}
|
||||
}
|
||||
lines = [
|
||||
_PROVIDER_MARKER_START,
|
||||
f'"provider": {json.dumps(provider, indent=2)},',
|
||||
_PROVIDER_MARKER_END,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_mcp_block(port: int) -> str:
|
||||
"""Render a Headroom MCP block as a JSON comment-wrapped snippet."""
|
||||
mcp = {
|
||||
"headroom": {
|
||||
"type": "remote",
|
||||
"url": f"http://127.0.0.1:{port}/mcp",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
lines = [
|
||||
_MCP_MARKER_START,
|
||||
f'"mcp": {json.dumps(mcp, indent=2)},',
|
||||
_MCP_MARKER_END,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_json_loose(text: str) -> dict[str, Any]:
|
||||
"""Parse JSON text, stripping line comments (// ...) when needed.
|
||||
|
||||
Tries standard JSON first to avoid corrupting URLs that contain ``//``.
|
||||
Falls back to stripping ``//`` comments when standard parsing fails.
|
||||
Two-pass: (1) remove comment-only lines, (2) strip inline trailing
|
||||
comments that follow a comma.
|
||||
"""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Pass 1: remove lines that are ONLY a comment.
|
||||
cleaned = re.sub(r"^\s*//[^\n]*\n", "", text, flags=re.MULTILINE)
|
||||
# Pass 2: remove inline trailing comments (", // comment").
|
||||
cleaned = re.sub(r",\s*//[^\n]*", ",", cleaned)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _inject_key_into_json(
|
||||
data: dict[str, Any], key: str, value: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Merge ``value`` into ``data[key]`` idempotently."""
|
||||
existing = data.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, dict):
|
||||
merged = {**existing, **value}
|
||||
data[key] = merged
|
||||
else:
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def append_headroom_plugin(config: dict[str, object]) -> bool:
|
||||
"""Append the optional OpenCode plugin entry if it is not already present."""
|
||||
plugin = config.get("plugin")
|
||||
if plugin is None:
|
||||
config["plugin"] = [HEADROOM_OPENCODE_PLUGIN]
|
||||
return True
|
||||
|
||||
if not isinstance(plugin, list):
|
||||
return False
|
||||
|
||||
for entry in plugin:
|
||||
if entry == HEADROOM_OPENCODE_PLUGIN:
|
||||
return False
|
||||
if isinstance(entry, list) and entry and entry[0] == HEADROOM_OPENCODE_PLUGIN:
|
||||
return False
|
||||
|
||||
plugin.append(HEADROOM_OPENCODE_PLUGIN)
|
||||
return True
|
||||
|
||||
|
||||
def inject_opencode_provider_config(port: int) -> None:
|
||||
"""Inject a Headroom model provider into OpenCode's config file.
|
||||
|
||||
Safe to call multiple times — the injected block is fully replaced on
|
||||
each call, so re-running with a different ``port`` updates the config.
|
||||
Before the first injection, the pre-wrap file is snapshotted to
|
||||
``opencode.json.headroom-backup`` so ``headroom unwrap opencode``
|
||||
can restore it byte-for-byte.
|
||||
"""
|
||||
config_file, backup_file = opencode_config_paths()
|
||||
config_dir = config_file.parent
|
||||
|
||||
try:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_opencode_config_if_unwrapped(config_file, backup_file)
|
||||
|
||||
if config_file.exists():
|
||||
content = config_file.read_text()
|
||||
data = _parse_json_loose(content)
|
||||
else:
|
||||
content = ""
|
||||
data = {}
|
||||
|
||||
# Strip any prior Headroom-managed blocks before re-injecting.
|
||||
if _PROVIDER_MARKER_START in content:
|
||||
content = strip_opencode_headroom_blocks(content)
|
||||
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"},
|
||||
}
|
||||
}
|
||||
data = _inject_key_into_json(data, "provider", provider)
|
||||
|
||||
# Inject MCP if not already present.
|
||||
mcp = {
|
||||
"headroom": {
|
||||
"type": "remote",
|
||||
"url": f"http://127.0.0.1:{port}/mcp",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
data = _inject_key_into_json(data, "mcp", mcp)
|
||||
|
||||
# Write back as formatted JSON (opencode uses standard JSON with comments).
|
||||
output = json.dumps(data, indent=2) + "\n"
|
||||
config_file.write_text(output, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise click.ClickException(
|
||||
f"could not write OpenCode config at {config_file}: {exc}"
|
||||
) from exc
|
||||
88
headroom/providers/opencode/install.py
Normal file
88
headroom/providers/opencode/install.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""OpenCode install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
|
||||
from headroom.install.paths import opencode_config_path
|
||||
|
||||
from .config import (
|
||||
_inject_key_into_json,
|
||||
_parse_json_loose,
|
||||
snapshot_opencode_config_if_unwrapped,
|
||||
strip_opencode_headroom_blocks,
|
||||
)
|
||||
from .runtime import proxy_base_url
|
||||
|
||||
|
||||
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
|
||||
"""Build the persistent install environment for OpenCode."""
|
||||
del backend
|
||||
del port
|
||||
return {}
|
||||
|
||||
|
||||
def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
|
||||
"""Apply OpenCode provider-scope configuration when requested."""
|
||||
if manifest.scope != ConfigScope.PROVIDER.value:
|
||||
return None
|
||||
|
||||
config_file = opencode_config_path()
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
snapshot_opencode_config_if_unwrapped(
|
||||
config_file, config_file.with_suffix(".json.headroom-backup")
|
||||
)
|
||||
|
||||
if config_file.exists():
|
||||
content = config_file.read_text()
|
||||
data = _parse_json_loose(content)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
provider = {
|
||||
"headroom": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Headroom Proxy",
|
||||
"options": {"baseURL": proxy_base_url(manifest.port)},
|
||||
}
|
||||
}
|
||||
data = _inject_key_into_json(data, "provider", provider)
|
||||
|
||||
config_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return ManagedMutation(
|
||||
target=ToolTarget.OPENCODE.value,
|
||||
kind="json-block",
|
||||
path=str(config_file),
|
||||
)
|
||||
|
||||
|
||||
def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
|
||||
"""Revert OpenCode provider-scope configuration.
|
||||
|
||||
Restores from pre-wrap backup when available, otherwise strips the
|
||||
headroom provider from the config file.
|
||||
"""
|
||||
del manifest
|
||||
if not mutation.path:
|
||||
return
|
||||
path = Path(mutation.path)
|
||||
backup_file = path.with_suffix(".json.headroom-backup")
|
||||
if backup_file.exists():
|
||||
try:
|
||||
shutil.copy2(backup_file, path)
|
||||
backup_file.unlink()
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
if not path.exists():
|
||||
return
|
||||
content = path.read_text()
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
if cleaned:
|
||||
path.write_text(cleaned + "\n", encoding="utf-8")
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
81
headroom/providers/opencode/runtime.py
Normal file
81
headroom/providers/opencode/runtime.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Runtime helpers for OpenCode integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .config import HEADROOM_OPENCODE_PLUGIN
|
||||
|
||||
|
||||
def proxy_base_url(port: int) -> str:
|
||||
"""Return the local proxy base URL used by OpenCode integrations."""
|
||||
return f"http://127.0.0.1:{port}/v1"
|
||||
|
||||
|
||||
def build_opencode_config_content(
|
||||
*,
|
||||
port: int,
|
||||
include_mcp: bool = True,
|
||||
include_plugin: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Build JSON payload for ``OPENCODE_CONFIG_CONTENT``.
|
||||
|
||||
Runtime wrap injects the Headroom provider as a stable explicit fallback,
|
||||
plus the Headroom plugin which transparently routes provider fetch traffic
|
||||
through the local proxy without rewriting user provider config URLs.
|
||||
"""
|
||||
base_url = proxy_base_url(port)
|
||||
config: dict[str, object] = {
|
||||
"provider": {
|
||||
"headroom": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Headroom Proxy",
|
||||
"options": {"baseURL": base_url},
|
||||
}
|
||||
}
|
||||
}
|
||||
if include_mcp:
|
||||
config["mcp"] = {
|
||||
"headroom": {
|
||||
"type": "remote",
|
||||
"url": f"http://127.0.0.1:{port}/mcp",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
if include_plugin:
|
||||
config["plugin"] = [[HEADROOM_OPENCODE_PLUGIN, {"proxyUrl": base_url}]]
|
||||
return config
|
||||
|
||||
|
||||
def build_launch_env(
|
||||
port: int,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
project: str | None = None,
|
||||
*,
|
||||
include_mcp: bool = True,
|
||||
include_plugin: bool = True,
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""Build environment variables for launching OpenCode through Headroom.
|
||||
|
||||
``OPENCODE_CONFIG_CONTENT`` carries Headroom provider/MCP/plugin config.
|
||||
Existing provider/base URL environment variables are preserved.
|
||||
"""
|
||||
env = dict(environ or os.environ)
|
||||
|
||||
config_content = build_opencode_config_content(
|
||||
port=port,
|
||||
include_mcp=include_mcp,
|
||||
include_plugin=include_plugin,
|
||||
)
|
||||
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config_content, separators=(",", ":"))
|
||||
|
||||
display = ["OPENCODE_CONFIG_CONTENT={provider: headroom}"]
|
||||
if include_plugin:
|
||||
display.append(f"plugin={HEADROOM_OPENCODE_PLUGIN}")
|
||||
|
||||
if project and "HEADROOM_PROJECT" not in env:
|
||||
env["HEADROOM_PROJECT"] = project
|
||||
|
||||
return env, display
|
||||
|
|
@ -283,9 +283,45 @@ def write_github_outputs(info: ReleaseVersionInfo, output_path: str) -> None:
|
|||
def main() -> None:
|
||||
root = Path.cwd()
|
||||
manual_version = os.environ.get("MANUAL_VER", "").strip()
|
||||
manual_match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:[abrc]\d+)?)", (os.environ.get("MANUAL_VER") or os.environ.get("LEVEL", "patch")).strip())
|
||||
if manual_match:
|
||||
version = manual_match.group(1)
|
||||
info = ReleaseVersionInfo(
|
||||
version=version,
|
||||
npm_version=version,
|
||||
canonical=get_canonical_version(root),
|
||||
bump="manual",
|
||||
height="0",
|
||||
previous_tag="",
|
||||
)
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_path:
|
||||
write_github_outputs(info, output_path)
|
||||
print(f"version={info.version}")
|
||||
print(f"npm_version={info.npm_version}")
|
||||
print(f"height={info.height}")
|
||||
return
|
||||
tags = list_release_tags(root)
|
||||
previous_tag = find_latest_release_tag(tags) or ""
|
||||
level = os.environ.get("LEVEL", "").strip()
|
||||
manual_match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:[abrc]\d+)?)", level.strip())
|
||||
if manual_match:
|
||||
version = manual_match.group(1)
|
||||
info = ReleaseVersionInfo(
|
||||
version=version,
|
||||
npm_version=version,
|
||||
canonical=get_canonical_version(root),
|
||||
bump="manual",
|
||||
height="0",
|
||||
previous_tag="",
|
||||
)
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_path:
|
||||
write_github_outputs(info, output_path)
|
||||
print(f"version={info.version}")
|
||||
print(f"npm_version={info.npm_version}")
|
||||
print(f"height={info.height}")
|
||||
return
|
||||
if not level:
|
||||
level = determine_bump_level(list_release_commits(root, previous_tag))
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from typing import Any
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_KNOWN_WRAP_AGENTS = frozenset({"claude", "copilot", "codex", "aider", "cursor", "openclaw"})
|
||||
_KNOWN_WRAP_AGENTS = frozenset({"claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"})
|
||||
|
||||
# Stack slugs must start with a letter and contain only [a-z0-9_], max 64 chars.
|
||||
# Applied at every ingress (env var, HTTP header, stats aggregation) so downstream
|
||||
|
|
|
|||
3
plugins/opencode/.gitignore
vendored
Normal file
3
plugins/opencode/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
*.log
|
||||
127
plugins/opencode/README.md
Normal file
127
plugins/opencode/README.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# headroom-opencode
|
||||
|
||||
Headroom proxy integration for [OpenCode](https://opencode.ai). Routes LLM traffic through the Headroom proxy for token compression, provides CCR retrieval, and handles provider configuration.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install headroom-opencode
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Create a Headroom provider for opencode.json
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
8
plugins/opencode/hook-shim/handler.js
Normal file
8
plugins/opencode/hook-shim/handler.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { installHeadroomTransport } from "../dist/index.js";
|
||||
|
||||
const proxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
if (!proxyUrl) {
|
||||
throw new Error("Headroom OpenCode transport shim loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL");
|
||||
}
|
||||
|
||||
installHeadroomTransport({ proxyUrl });
|
||||
46
plugins/opencode/package.json
Normal file
46
plugins/opencode/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "headroom-opencode",
|
||||
"version": "0.1.0",
|
||||
"description": "Headroom proxy integration plugin for OpenCode - routes LLM traffic through the Headroom proxy for token compression",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"hook-shim",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "^1.17.8",
|
||||
"headroom-ai": "^0.22.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ai-sdk/openai-compatible": "*",
|
||||
"@ai-sdk/provider": "*",
|
||||
"ai": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ai-sdk/openai-compatible": {
|
||||
"optional": true
|
||||
},
|
||||
"@ai-sdk/provider": {
|
||||
"optional": true
|
||||
},
|
||||
"ai": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
23
plugins/opencode/src/index.ts
Normal file
23
plugins/opencode/src/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export {
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_MODELS,
|
||||
buildOpencodeConfigContent,
|
||||
buildOpencodeConfigContentJson,
|
||||
createHeadroomProvider,
|
||||
} from "./provider.js";
|
||||
export type {
|
||||
HeadroomModelMapping,
|
||||
HeadroomProvider,
|
||||
HeadroomProviderOptions,
|
||||
} from "./provider.js";
|
||||
export {
|
||||
compressWithHeadroom,
|
||||
createHeadroomRetrieveTool,
|
||||
getDefaultProxyUrl,
|
||||
setDefaultProxyUrl,
|
||||
} from "./retrieve.js";
|
||||
export type { RetrieveToolConfig } from "./retrieve.js";
|
||||
export { HeadroomPlugin, default } from "./plugin.js";
|
||||
export type { HeadroomOpenCodePluginOptions } from "./plugin.js";
|
||||
|
||||
export { installHeadroomTransport } from "./transport.js";
|
||||
68
plugins/opencode/src/plugin.test.ts
Normal file
68
plugins/opencode/src/plugin.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HeadroomPlugin } from "./plugin.js";
|
||||
|
||||
function pluginInput() {
|
||||
return {
|
||||
client: {},
|
||||
project: { id: "project-1" },
|
||||
directory: "/repo",
|
||||
worktree: "/repo",
|
||||
experimental_workspace: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
$: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("HeadroomPlugin", () => {
|
||||
it("adds only Headroom metadata to shell env", async () => {
|
||||
const plugin = await HeadroomPlugin(pluginInput(), {
|
||||
proxyUrl: "http://127.0.0.1:8787/",
|
||||
backend: "litellm",
|
||||
});
|
||||
const output = {
|
||||
env: {
|
||||
OPENAI_BASE_URL: "https://deepseek.example/v1",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
},
|
||||
};
|
||||
|
||||
await plugin["shell.env"]?.({ cwd: "/repo" }, output);
|
||||
|
||||
expect(output.env).toMatchObject({
|
||||
HEADROOM_ACTIVE: "1",
|
||||
HEADROOM_PROXY_URL: "http://127.0.0.1:8787",
|
||||
HEADROOM_PROJECT: "project-1",
|
||||
HEADROOM_BACKEND: "litellm",
|
||||
OPENAI_BASE_URL: "https://deepseek.example/v1",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a headroom_retrieve tool backed by the proxy", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => "original content",
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const plugin = await HeadroomPlugin(pluginInput(), {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
});
|
||||
const result = await plugin.tool?.headroom_retrieve.execute(
|
||||
{ hash: "0123456789abcdef01234567", query: "needle" },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toBe("original content");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:8787/v1/retrieve/0123456789abcdef01234567?query=needle",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
69
plugins/opencode/src/plugin.ts
Normal file
69
plugins/opencode/src/plugin.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createHeadroomRetrieveTool, getDefaultProxyUrl } from "./retrieve.js";
|
||||
import { installHeadroomTransport } from "./transport.js";
|
||||
|
||||
export interface HeadroomOpenCodePluginOptions {
|
||||
proxyUrl?: string;
|
||||
project?: string;
|
||||
backend?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
|
||||
return normalizeProxyUrl(
|
||||
options?.proxyUrl ??
|
||||
process.env.HEADROOM_PROXY_URL ??
|
||||
process.env.HEADROOM_BASE_URL ??
|
||||
getDefaultProxyUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
export const HeadroomPlugin: Plugin = async (input, options = {}) => {
|
||||
const pluginOptions = options as HeadroomOpenCodePluginOptions;
|
||||
const proxyUrl = resolveProxyUrl(pluginOptions);
|
||||
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
|
||||
const uninstallTransport = installHeadroomTransport({
|
||||
proxyUrl,
|
||||
debug: pluginOptions.debug,
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: async () => {
|
||||
uninstallTransport();
|
||||
},
|
||||
tool: {
|
||||
headroom_retrieve: tool({
|
||||
description: retrieveTool.description,
|
||||
args: {
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{24}$/i, "Expected 24-character hex hash"),
|
||||
query: z.string().optional(),
|
||||
},
|
||||
async execute(args) {
|
||||
return retrieveTool.execute(args);
|
||||
},
|
||||
}),
|
||||
},
|
||||
"shell.env": async (_input, output) => {
|
||||
output.env.HEADROOM_ACTIVE = "1";
|
||||
output.env.HEADROOM_PROXY_URL = proxyUrl;
|
||||
output.env.HEADROOM_PROJECT =
|
||||
pluginOptions.project ??
|
||||
(input.project as { id?: string }).id ??
|
||||
input.directory;
|
||||
if (pluginOptions.backend) {
|
||||
output.env.HEADROOM_BACKEND = pluginOptions.backend;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default HeadroomPlugin;
|
||||
92
plugins/opencode/src/provider.ts
Normal file
92
plugins/opencode/src/provider.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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));
|
||||
}
|
||||
94
plugins/opencode/src/retrieve.ts
Normal file
94
plugins/opencode/src/retrieve.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { CompressResult } from "headroom-ai";
|
||||
import { compress } from "headroom-ai";
|
||||
|
||||
let _proxyUrlCache: string | null = null;
|
||||
|
||||
export function setDefaultProxyUrl(url: string): void {
|
||||
_proxyUrlCache = url;
|
||||
}
|
||||
|
||||
export function getDefaultProxyUrl(): string {
|
||||
return _proxyUrlCache ?? process.env.HEADROOM_BASE_URL ?? "http://localhost:8787";
|
||||
}
|
||||
|
||||
export interface RetrieveToolConfig {
|
||||
proxyBaseUrl: string;
|
||||
}
|
||||
|
||||
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
|
||||
const origin = config.proxyBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
name: "headroom_retrieve",
|
||||
description:
|
||||
"Retrieve original uncompressed content from Headroom's compression store. " +
|
||||
"Use when compressed context mentions a hash and you need the full details. " +
|
||||
"Pass the hash from the compression marker (24 hex characters). " +
|
||||
"Optionally pass a query to search within the original content.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
hash: {
|
||||
type: "string",
|
||||
description: "The 24-character hex hash from the compression marker",
|
||||
},
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Optional search query to filter results within the original content",
|
||||
},
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
execute: async (args: { hash: string; query?: string }): Promise<string> => {
|
||||
const { hash, query } = args;
|
||||
|
||||
if (!/^[a-f0-9]{24}$/i.test(hash)) {
|
||||
return JSON.stringify({
|
||||
error: "Invalid hash format. Expected 24 hex characters.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = query
|
||||
? `${origin}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
|
||||
: `${origin}/v1/retrieve/${hash}`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: HTTP ${resp.status}`,
|
||||
details: body,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return typeof data === "string" ? data : JSON.stringify(data);
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: ${error}`,
|
||||
hint: "The compressed content may have expired (default TTL: 5 minutes)",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressWithHeadroom(
|
||||
messages: unknown[],
|
||||
options: {
|
||||
model?: string;
|
||||
tokenBudget?: number;
|
||||
proxyUrl?: string;
|
||||
} = {},
|
||||
): Promise<CompressResult> {
|
||||
return compress(messages, {
|
||||
baseUrl: options.proxyUrl ?? getDefaultProxyUrl(),
|
||||
model: options.model ?? "gpt-4o",
|
||||
tokenBudget: options.tokenBudget,
|
||||
stack: "opencode",
|
||||
});
|
||||
}
|
||||
215
plugins/opencode/src/transport.test.ts
Normal file
215
plugins/opencode/src/transport.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import childProcess from "node:child_process";
|
||||
import http from "node:http";
|
||||
import http2 from "node:http2";
|
||||
import https from "node:https";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { installHeadroomTransport, uninstallHeadroomTransport } from "./transport.js";
|
||||
|
||||
afterEach(() => {
|
||||
uninstallHeadroomTransport();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type FetchCall = [RequestInfo | URL, RequestInit?];
|
||||
|
||||
type SeenRequest = {
|
||||
method: string | undefined;
|
||||
url: string | undefined;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => Promise<void> }> {
|
||||
const seen: SeenRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
seen.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end("{\"ok\":true}");
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Expected TCP server address"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${address.port}/v1`,
|
||||
seen,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("Headroom OpenCode transport", () => {
|
||||
it("routes external fetch calls through the proxy without pre-registering providers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
await fetch("https://api.deepseek.com/v1/chat/completions?x=1", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
await fetch("https://new-provider.example/base/v1/messages", { method: "POST" });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
new URL("http://127.0.0.1:8787/v1/chat/completions?x=1"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-base-url")).toBe(
|
||||
"https://api.deepseek.com",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
|
||||
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get("x-headroom-base-url")).toBe(
|
||||
"https://new-provider.example",
|
||||
);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("bypasses local, OpenCode, and Headroom proxy fetch URLs", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
await fetch("http://127.0.0.1:8787/v1/retrieve");
|
||||
await fetch("http://localhost:4096/config");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8787/v1/retrieve");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("http://localhost:4096/config");
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("routes external https.request calls through the proxy", async () => {
|
||||
const proxy = await proxyServer();
|
||||
installHeadroomTransport({ proxyUrl: proxy.url });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
"https://api.anthropic.com/v1/messages?beta=1",
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"claude\"}");
|
||||
});
|
||||
|
||||
expect(proxy.seen).toHaveLength(1);
|
||||
expect(proxy.seen[0]).toMatchObject({ method: "POST", url: "/v1/messages?beta=1" });
|
||||
expect(proxy.seen[0].headers["x-headroom-base-url"]).toBe("https://api.anthropic.com");
|
||||
expect(proxy.seen[0].headers.host).toMatch(/^127\.0\.0\.1:/);
|
||||
expect(proxy.seen[0].body).toBe("{\"model\":\"claude\"}");
|
||||
|
||||
await proxy.close();
|
||||
});
|
||||
|
||||
it("blocks external http2 connections instead of leaking them", () => {
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
expect(() => http2.connect("https://api.openai.com")).toThrow(
|
||||
/blocked direct HTTP\/2 connection to https:\/\/api\.openai\.com/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preloads the Headroom shim into child Node processes", () => {
|
||||
const originalNodeOptions = process.env.NODE_OPTIONS;
|
||||
const originalProxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
|
||||
try {
|
||||
process.env.NODE_OPTIONS = "--trace-warnings";
|
||||
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
expect(process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
|
||||
expect(process.env.NODE_OPTIONS).toContain("--trace-warnings");
|
||||
expect(process.env.NODE_OPTIONS).toContain("--import=file:");
|
||||
expect(process.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
expect(process.env.NODE_OPTIONS?.match(/hook-shim\/handler\.js/g)).toHaveLength(1);
|
||||
} finally {
|
||||
if (originalNodeOptions === undefined) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = originalNodeOptions;
|
||||
}
|
||||
if (originalProxyUrl === undefined) {
|
||||
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
} else {
|
||||
process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL = originalProxyUrl;
|
||||
}
|
||||
uninstallHeadroomTransport();
|
||||
}
|
||||
});
|
||||
|
||||
it("injects the Headroom shim into child processes with custom env", () => {
|
||||
const originalSpawn = childProcess.spawn;
|
||||
const spawnMock = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
pid: 123,
|
||||
}));
|
||||
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
|
||||
|
||||
try {
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
childProcess.spawn("node", ["agent.js"], { env: { PATH: "/bin", NODE_OPTIONS: "--trace-warnings" } });
|
||||
|
||||
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
|
||||
expect(options.env.PATH).toBe("/bin");
|
||||
expect(options.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
|
||||
expect(options.env.NODE_OPTIONS).toContain("--trace-warnings");
|
||||
expect(options.env.NODE_OPTIONS).toContain("--import=file:");
|
||||
expect(options.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
|
||||
} finally {
|
||||
uninstallHeadroomTransport();
|
||||
childProcess.spawn = originalSpawn;
|
||||
}
|
||||
});
|
||||
|
||||
it("restores patched transports only after the final disposer", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalHttpRequest = http.request;
|
||||
const originalHttpsRequest = https.request;
|
||||
const firstDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
const secondDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8788/v1" });
|
||||
|
||||
expect(globalThis.fetch).not.toBe(originalFetch);
|
||||
expect(http.request).not.toBe(originalHttpRequest);
|
||||
expect(https.request).not.toBe(originalHttpsRequest);
|
||||
|
||||
firstDispose();
|
||||
expect(globalThis.fetch).not.toBe(originalFetch);
|
||||
expect(http.request).not.toBe(originalHttpRequest);
|
||||
|
||||
secondDispose();
|
||||
expect(globalThis.fetch).toBe(originalFetch);
|
||||
expect(http.request).toBe(originalHttpRequest);
|
||||
expect(https.request).toBe(originalHttpsRequest);
|
||||
});
|
||||
});
|
||||
438
plugins/opencode/src/transport.ts
Normal file
438
plugins/opencode/src/transport.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const http = nodeRequire("node:http") as typeof import("node:http");
|
||||
const https = nodeRequire("node:https") as typeof import("node:https");
|
||||
const http2 = nodeRequire("node:http2") as typeof import("node:http2");
|
||||
const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process");
|
||||
|
||||
const BASE_URL_HEADER = "x-headroom-base-url";
|
||||
const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
|
||||
const STATE_KEY = Symbol.for("headroom.opencode.transport");
|
||||
|
||||
type FetchArgs = Parameters<typeof fetch>;
|
||||
type HttpRequest = typeof http.request;
|
||||
type HttpGet = typeof http.get;
|
||||
type HttpsRequest = typeof https.request;
|
||||
type HttpsGet = typeof https.get;
|
||||
type Http2Connect = typeof http2.connect;
|
||||
type ChildSpawn = typeof childProcess.spawn;
|
||||
type ChildExec = typeof childProcess.exec;
|
||||
type ChildExecFile = typeof childProcess.execFile;
|
||||
type ChildFork = typeof childProcess.fork;
|
||||
|
||||
interface InstallOptions {
|
||||
proxyUrl: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
interface TransportState {
|
||||
refs: number;
|
||||
proxyUrl: string;
|
||||
debug: boolean;
|
||||
originalFetch: typeof fetch;
|
||||
originalHttpRequest: HttpRequest;
|
||||
originalHttpGet: HttpGet;
|
||||
originalHttpsRequest: HttpsRequest;
|
||||
originalHttpsGet: HttpsGet;
|
||||
originalHttp2Connect: Http2Connect;
|
||||
originalChildSpawn: ChildSpawn;
|
||||
originalChildExec: ChildExec;
|
||||
originalChildExecFile: ChildExecFile;
|
||||
originalChildFork: ChildFork;
|
||||
}
|
||||
|
||||
interface GlobalWithHeadroomTransport {
|
||||
[STATE_KEY]?: TransportState;
|
||||
}
|
||||
|
||||
interface NodeRequestParts {
|
||||
url?: URL;
|
||||
options: Record<string, unknown>;
|
||||
callback?: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function getState(): TransportState | undefined {
|
||||
return (globalThis as GlobalWithHeadroomTransport)[STATE_KEY];
|
||||
}
|
||||
|
||||
function setState(state: TransportState | undefined): void {
|
||||
(globalThis as GlobalWithHeadroomTransport)[STATE_KEY] = state;
|
||||
}
|
||||
|
||||
function shimImportSpecifier(): string {
|
||||
return new URL("../hook-shim/handler.js", import.meta.url).href;
|
||||
}
|
||||
|
||||
function withNodeImportOption(existing: string | undefined, shim: string): string {
|
||||
const parts = existing?.trim() ? existing.trim().split(/\s+/) : [];
|
||||
const alreadyPresent = parts.some((part, index) => {
|
||||
return part === `--import=${shim}` || (part === "--import" && parts[index + 1] === shim);
|
||||
});
|
||||
if (!alreadyPresent) {
|
||||
parts.push(`--import=${shim}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function withShimEnv(env: NodeJS.ProcessEnv | Record<string, unknown> | undefined, proxyUrl: string): NodeJS.ProcessEnv {
|
||||
const nextEnv = { ...(env ?? process.env) } as NodeJS.ProcessEnv;
|
||||
nextEnv[PROXY_ENV] = proxyUrl;
|
||||
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
function installProcessEnv(proxyUrl: string): void {
|
||||
process.env[PROXY_ENV] = proxyUrl;
|
||||
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
|
||||
}
|
||||
|
||||
function isOptions(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof URL);
|
||||
}
|
||||
|
||||
function injectOptionsEnv(args: unknown[], optionIndex: number, proxyUrl: string): unknown[] {
|
||||
const nextArgs = [...args];
|
||||
const callback = typeof nextArgs.at(-1) === "function" ? nextArgs.pop() : undefined;
|
||||
const existing = isOptions(nextArgs[optionIndex]) ? { ...(nextArgs[optionIndex] as Record<string, unknown>) } : {};
|
||||
existing.env = withShimEnv(existing.env as NodeJS.ProcessEnv | undefined, proxyUrl);
|
||||
|
||||
if (isOptions(nextArgs[optionIndex])) {
|
||||
nextArgs[optionIndex] = existing;
|
||||
} else {
|
||||
nextArgs.splice(optionIndex, 0, existing);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
nextArgs.push(callback);
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
function wrapSpawn(originalSpawn: ChildSpawn): ChildSpawn {
|
||||
return function headroomSpawn(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalSpawn, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalSpawn, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildSpawn;
|
||||
}
|
||||
|
||||
function wrapExec(originalExec: ChildExec): ChildExec {
|
||||
return function headroomExec(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalExec, this, args);
|
||||
}
|
||||
return Reflect.apply(originalExec, this, injectOptionsEnv(args, 1, state.proxyUrl));
|
||||
} as ChildExec;
|
||||
}
|
||||
|
||||
function wrapExecFile(originalExecFile: ChildExecFile): ChildExecFile {
|
||||
return function headroomExecFile(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalExecFile, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalExecFile, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildExecFile;
|
||||
}
|
||||
|
||||
function wrapFork(originalFork: ChildFork): ChildFork {
|
||||
return function headroomFork(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalFork, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalFork, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildFork;
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(proxyUrl: string): URL {
|
||||
return new URL(proxyUrl);
|
||||
}
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function shouldRoute(url: URL, proxy: URL): boolean {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
if (isLoopback(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
if (url.origin === proxy.origin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function routedUrl(upstream: URL, proxy: URL): URL {
|
||||
return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
if (input instanceof Request) {
|
||||
return new URL(input.url);
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input;
|
||||
}
|
||||
return new URL(String(input));
|
||||
}
|
||||
|
||||
function mergeFetchHeaders(input: RequestInfo | URL, init?: RequestInit, upstream?: URL): Headers {
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||
if (init?.headers) {
|
||||
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||
}
|
||||
if (upstream) {
|
||||
headers.set(BASE_URL_HEADER, upstream.origin);
|
||||
headers.delete("host");
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL): FetchArgs {
|
||||
const upstream = requestUrl(input);
|
||||
if (!shouldRoute(upstream, proxy)) {
|
||||
return [input, init];
|
||||
}
|
||||
|
||||
const nextInit = {
|
||||
...init,
|
||||
headers: mergeFetchHeaders(input, init, upstream),
|
||||
};
|
||||
const nextUrl = routedUrl(upstream, proxy);
|
||||
|
||||
if (input instanceof Request) {
|
||||
return [new Request(nextUrl, input), nextInit];
|
||||
}
|
||||
return [nextUrl, nextInit];
|
||||
}
|
||||
|
||||
function splitNodeArgs(args: unknown[]): NodeRequestParts {
|
||||
const callback = typeof args.at(-1) === "function" ? (args.at(-1) as (...args: unknown[]) => unknown) : undefined;
|
||||
const withoutCallback = callback ? args.slice(0, -1) : args;
|
||||
const [first, second] = withoutCallback;
|
||||
const options = typeof second === "object" && second !== null ? { ...(second as Record<string, unknown>) } : {};
|
||||
|
||||
if (first instanceof URL) {
|
||||
return { url: first, options, callback };
|
||||
}
|
||||
if (typeof first === "string") {
|
||||
try {
|
||||
return { url: new URL(first), options, callback };
|
||||
} catch {
|
||||
return { options, callback };
|
||||
}
|
||||
}
|
||||
if (typeof first === "object" && first !== null) {
|
||||
const requestOptions = { ...(first as Record<string, unknown>), ...options };
|
||||
return { url: urlFromRequestOptions(requestOptions), options: requestOptions, callback };
|
||||
}
|
||||
return { options, callback };
|
||||
}
|
||||
|
||||
function urlFromRequestOptions(options: Record<string, unknown>): URL | undefined {
|
||||
const protocol = String(options.protocol ?? "http:");
|
||||
if (protocol !== "http:" && protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostValue = options.hostname ?? options.host;
|
||||
if (!hostValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostname = String(hostValue).replace(/:\d+$/, "");
|
||||
const port = options.port ? `:${String(options.port)}` : "";
|
||||
const path = String(options.path ?? "/");
|
||||
try {
|
||||
return new URL(`${protocol}//${hostname}${port}${path}`);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function headersForNodeRequest(options: Record<string, unknown>, upstream: URL): Record<string, string> {
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
headers.set(BASE_URL_HEADER, upstream.origin);
|
||||
headers.delete("host");
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string, unknown> | undefined {
|
||||
if (!parts.url || !shouldRoute(parts.url, proxy)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextUrl = routedUrl(parts.url, proxy);
|
||||
const {
|
||||
agent: _agent,
|
||||
auth: _auth,
|
||||
createConnection: _createConnection,
|
||||
defaultPort: _defaultPort,
|
||||
family: _family,
|
||||
headers: _headers,
|
||||
host: _host,
|
||||
hostname: _hostname,
|
||||
href: _href,
|
||||
lookup: _lookup,
|
||||
path: _path,
|
||||
pathname: _pathname,
|
||||
port: _port,
|
||||
protocol: _protocol,
|
||||
search: _search,
|
||||
servername: _servername,
|
||||
setHost: _setHost,
|
||||
...rest
|
||||
} = parts.options;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
protocol: nextUrl.protocol,
|
||||
hostname: nextUrl.hostname,
|
||||
port: nextUrl.port || undefined,
|
||||
path: `${nextUrl.pathname}${nextUrl.search}`,
|
||||
headers: headersForNodeRequest(parts.options, parts.url),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapRequest(
|
||||
originalHttpRequest: HttpRequest,
|
||||
originalHttpsRequest: HttpsRequest,
|
||||
originalRequest: HttpRequest | HttpsRequest,
|
||||
): HttpRequest | HttpsRequest {
|
||||
return function headroomRequest(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalRequest, this, args);
|
||||
}
|
||||
|
||||
const proxy = normalizeProxyUrl(state.proxyUrl);
|
||||
const parts = splitNodeArgs(args);
|
||||
const nextOptions = routedNodeOptions(parts, proxy);
|
||||
if (!nextOptions) {
|
||||
return Reflect.apply(originalRequest, this, args);
|
||||
}
|
||||
|
||||
const targetRequest = proxy.protocol === "https:" ? originalHttpsRequest : originalHttpRequest;
|
||||
const nextArgs = parts.callback ? [nextOptions, parts.callback] : [nextOptions];
|
||||
return Reflect.apply(targetRequest, this, nextArgs);
|
||||
} as HttpRequest | HttpsRequest;
|
||||
}
|
||||
|
||||
function wrapGet(request: HttpRequest | HttpsRequest): HttpGet | HttpsGet {
|
||||
return function headroomGet(this: unknown, ...args: unknown[]) {
|
||||
const req = Reflect.apply(request, this, args);
|
||||
req.end();
|
||||
return req;
|
||||
} as HttpGet | HttpsGet;
|
||||
}
|
||||
|
||||
function wrapHttp2Connect(originalConnect: Http2Connect): Http2Connect {
|
||||
return function headroomHttp2Connect(this: unknown, authority: string | URL, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (state) {
|
||||
const proxy = normalizeProxyUrl(state.proxyUrl);
|
||||
const upstream = authority instanceof URL ? authority : new URL(String(authority));
|
||||
if (shouldRoute(upstream, proxy)) {
|
||||
throw new Error(
|
||||
`Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. ` +
|
||||
"Use fetch, http, or https so traffic can be routed through Headroom.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return Reflect.apply(originalConnect, this, [authority, ...args]);
|
||||
} as Http2Connect;
|
||||
}
|
||||
|
||||
export function installHeadroomTransport(options: InstallOptions): () => void {
|
||||
const existing = getState();
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
existing.proxyUrl = options.proxyUrl;
|
||||
existing.debug = Boolean(options.debug);
|
||||
installProcessEnv(options.proxyUrl);
|
||||
return () => uninstallHeadroomTransport();
|
||||
}
|
||||
|
||||
const state: TransportState = {
|
||||
refs: 1,
|
||||
proxyUrl: options.proxyUrl,
|
||||
debug: Boolean(options.debug),
|
||||
originalFetch: globalThis.fetch,
|
||||
originalHttpRequest: http.request,
|
||||
originalHttpGet: http.get,
|
||||
originalHttpsRequest: https.request,
|
||||
originalHttpsGet: https.get,
|
||||
originalHttp2Connect: http2.connect,
|
||||
originalChildSpawn: childProcess.spawn,
|
||||
originalChildExec: childProcess.exec,
|
||||
originalChildExecFile: childProcess.execFile,
|
||||
originalChildFork: childProcess.fork,
|
||||
};
|
||||
|
||||
setState(state);
|
||||
installProcessEnv(options.proxyUrl);
|
||||
globalThis.fetch = async (...args: FetchArgs) => {
|
||||
const current = getState();
|
||||
if (!current) {
|
||||
return state.originalFetch(...args);
|
||||
}
|
||||
const proxy = normalizeProxyUrl(current.proxyUrl);
|
||||
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
|
||||
return state.originalFetch(nextInput, nextInit);
|
||||
};
|
||||
|
||||
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest) as HttpRequest;
|
||||
https.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpsRequest) as HttpsRequest;
|
||||
http.get = wrapGet(http.request) as HttpGet;
|
||||
https.get = wrapGet(https.request) as HttpsGet;
|
||||
http2.connect = wrapHttp2Connect(state.originalHttp2Connect);
|
||||
childProcess.spawn = wrapSpawn(state.originalChildSpawn);
|
||||
childProcess.exec = wrapExec(state.originalChildExec);
|
||||
childProcess.execFile = wrapExecFile(state.originalChildExecFile);
|
||||
childProcess.fork = wrapFork(state.originalChildFork);
|
||||
syncBuiltinESMExports();
|
||||
|
||||
return () => uninstallHeadroomTransport();
|
||||
}
|
||||
|
||||
export function uninstallHeadroomTransport(): void {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.refs -= 1;
|
||||
if (state.refs > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.fetch = state.originalFetch;
|
||||
http.request = state.originalHttpRequest;
|
||||
http.get = state.originalHttpGet;
|
||||
https.request = state.originalHttpsRequest;
|
||||
https.get = state.originalHttpsGet;
|
||||
http2.connect = state.originalHttp2Connect;
|
||||
childProcess.spawn = state.originalChildSpawn;
|
||||
childProcess.exec = state.originalChildExec;
|
||||
childProcess.execFile = state.originalChildExecFile;
|
||||
childProcess.fork = state.originalChildFork;
|
||||
syncBuiltinESMExports();
|
||||
setState(undefined);
|
||||
}
|
||||
19
plugins/opencode/tsconfig.json
Normal file
19
plugins/opencode/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
10
plugins/opencode/tsup.config.ts
Normal file
10
plugins/opencode/tsup.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["headroom-ai"],
|
||||
});
|
||||
8
plugins/opencode/vitest.config.ts
Normal file
8
plugins/opencode/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -1611,7 +1611,7 @@ switch ($args[0]) {
|
|||
}
|
||||
|
||||
if ($args.Count -lt 2) {
|
||||
Fail 'Usage: headroom wrap <claude|codex|aider|cursor|openclaw> [...]'
|
||||
Fail 'Usage: headroom wrap <claude|codex|aider|cursor|openclaw|opencode> [...]'
|
||||
}
|
||||
|
||||
$tool = $args[1]
|
||||
|
|
@ -1623,8 +1623,9 @@ switch ($args[0]) {
|
|||
'aider' { }
|
||||
'cursor' { }
|
||||
'openclaw' { }
|
||||
'opencode' { }
|
||||
default {
|
||||
Fail "Docker-native wrapper does not support 'wrap $tool'. Supported targets: claude, codex, aider, cursor, openclaw"
|
||||
Fail "Docker-native wrapper does not support 'wrap $tool'. Supported targets: claude, codex, aider, cursor, openclaw, opencode"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1476,15 +1476,15 @@ main() {
|
|||
return
|
||||
fi
|
||||
|
||||
(($# >= 2)) || die "Usage: headroom wrap <claude|codex|aider|cursor|openclaw> [...]"
|
||||
(($# >= 2)) || die "Usage: headroom wrap <claude|codex|aider|cursor|openclaw|opencode> [...]"
|
||||
local tool="$2"
|
||||
shift 2
|
||||
|
||||
case "${tool}" in
|
||||
claude|codex|aider|cursor|openclaw)
|
||||
claude|codex|aider|cursor|openclaw|opencode)
|
||||
;;
|
||||
*)
|
||||
die "Docker-native wrapper does not support 'wrap ${tool}'. Supported targets: claude, codex, aider, cursor, openclaw"
|
||||
die "Docker-native wrapper does not support 'wrap ${tool}'. Supported targets: claude, codex, aider, cursor, openclaw, opencode"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,16 @@ def _reset_headroom_logger_propagation():
|
|||
"""
|
||||
import logging as _logging
|
||||
|
||||
_logging.getLogger("headroom").propagate = True
|
||||
for logger_name in (
|
||||
"headroom",
|
||||
"headroom.proxy",
|
||||
"headroom.proxy.forwarded_headers",
|
||||
"headroom.transforms",
|
||||
"headroom.transforms.kompress_compressor",
|
||||
):
|
||||
logger = _logging.getLogger(logger_name)
|
||||
logger.disabled = False
|
||||
logger.propagate = True
|
||||
yield
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ def test_install_apply_rejects_provider_scope_targets_without_support() -> None:
|
|||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Provider scope supports only claude, codex, and openclaw" in result.output
|
||||
assert "Provider scope supports only claude, codex, openclaw, and opencode" in result.output
|
||||
|
||||
|
||||
def test_install_apply_restores_previous_deployment_after_failed_update(monkeypatch) -> None:
|
||||
|
|
|
|||
840
tests/test_cli/test_wrap_opencode.py
Normal file
840
tests/test_cli/test_wrap_opencode.py
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
"""Tests for `headroom wrap opencode` and `headroom unwrap opencode`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from headroom.cli import wrap as wrap_mod
|
||||
from headroom.cli.main import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner() -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
|
||||
def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
home = str(tmp_path)
|
||||
monkeypatch.setenv("HOME", home)
|
||||
monkeypatch.setenv("USERPROFILE", home)
|
||||
monkeypatch.delenv("OPENCODE_HOME", raising=False)
|
||||
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wrap opencode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wrap_opencode_sets_config_content_env(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""OPENCODE_CONFIG_CONTENT env var is set with the headroom provider."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://deepseek.example/v1")
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.example")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, ["wrap", "opencode", "--port", "9000", "--no-mcp", "--", "--model", "gpt-4o"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert "OPENCODE_CONFIG_CONTENT" in env
|
||||
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
||||
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
|
||||
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
|
||||
assert "model" not in config # headroom provider is a transparent pass-through
|
||||
assert captured["tool_label"] == "OPENCODE"
|
||||
assert captured["agent_type"] == "opencode"
|
||||
assert captured["args"] == ("--model", "gpt-4o")
|
||||
|
||||
|
||||
def test_wrap_opencode_does_not_add_base_url_env_vars(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""OPENAI_BASE_URL and ANTHROPIC_BASE_URL are left to OpenCode providers."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://deepseek.example/v1")
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.example")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["OPENAI_BASE_URL"] == "https://deepseek.example/v1"
|
||||
assert env["ANTHROPIC_BASE_URL"] == "https://anthropic.example"
|
||||
|
||||
|
||||
def test_wrap_opencode_missing_binary_errors_clearly(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If the opencode binary is missing the command must fail with a clear error."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value=None):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "'opencode' not found in PATH" in result.output
|
||||
|
||||
|
||||
def test_wrap_opencode_prepare_only_injects_config(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`wrap opencode --prepare-only` writes the provider config to opencode.json."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--prepare-only"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
assert config_file.exists()
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
|
||||
|
||||
|
||||
def test_wrap_opencode_no_mcp_skips_mcp_injection(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`--no-mcp` skips MCP server injection."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
||||
assert "mcp" not in config
|
||||
|
||||
|
||||
def test_wrap_opencode_injects_mcp_by_default(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""MCP is included in OPENCODE_CONFIG_CONTENT by default."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
||||
assert "mcp" in config
|
||||
assert config["mcp"]["headroom"]["type"] == "remote"
|
||||
|
||||
|
||||
def test_wrap_opencode_injects_rtk_into_agents_md(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""RTK instructions are injected into global and project AGENTS.md."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
|
||||
project_agents = tmp_path / "AGENTS.md"
|
||||
assert global_agents.exists(), "Global AGENTS.md should be created"
|
||||
assert project_agents.exists(), "Project AGENTS.md should be created"
|
||||
assert wrap_mod._RTK_MARKER in global_agents.read_text()
|
||||
assert wrap_mod._RTK_MARKER in project_agents.read_text()
|
||||
|
||||
|
||||
def test_wrap_opencode_idempotent_no_duplicate_block(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Running wrap twice must not duplicate the RTK block in AGENTS.md."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
project_agents = tmp_path / "AGENTS.md"
|
||||
content = project_agents.read_text()
|
||||
assert content.count(wrap_mod._RTK_MARKER) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unwrap opencode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unwrap_opencode_restores_from_backup(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unwrap restores the pre-wrap backup and removes it."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
backup_file = config_file.with_suffix(".json.headroom-backup")
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = '{"model": "openai/gpt-4o"}'
|
||||
config_file.write_text(original)
|
||||
backup_file.write_text(original)
|
||||
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
result = runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Restored prior" in result.output
|
||||
assert not backup_file.exists()
|
||||
assert config_file.read_text() == original
|
||||
|
||||
|
||||
def test_unwrap_opencode_strips_blocks_when_no_backup(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unwrap strips Headroom blocks when no backup exists."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
user_content = '{"model": "openai/gpt-4o"}'
|
||||
wrapped_content = (
|
||||
wrap_mod._PROVIDER_MARKER_START
|
||||
+ '\n"provider": {},\n'
|
||||
+ wrap_mod._PROVIDER_MARKER_END
|
||||
+ "\n"
|
||||
+ user_content
|
||||
)
|
||||
config_file.write_text(wrapped_content)
|
||||
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
result = runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Removed Headroom block" in result.output
|
||||
assert user_content in config_file.read_text()
|
||||
assert wrap_mod._PROVIDER_MARKER_START not in config_file.read_text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases — wrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wrap_opencode_preserves_existing_user_providers(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Wrap merges headroom provider without disturbing user's existing providers."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text('{"provider": {"openai": {"models": {"gpt-4o": {"name": "GPT-4o"}}}}}')
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config = json.loads(config_file.read_text())
|
||||
assert "headroom" in config["provider"], "headroom provider not injected"
|
||||
assert "openai" in config["provider"], "user's openai provider was removed"
|
||||
|
||||
|
||||
def test_wrap_opencode_port_change_updates_existing_config(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Wrapping with a different port updates the baseURL in opencode.json."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
runner.invoke(main, ["wrap", "opencode", "--port", "9001", "--no-mcp"])
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9001/v1"
|
||||
|
||||
|
||||
def test_wrap_opencode_handles_malformed_config_file(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Wrap handles a malformed opencode.json by backing it up before overwriting."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
malformed = '{"model": "gpt-4o",}' # trailing comma
|
||||
config_file.write_text(malformed)
|
||||
backup_file = config_file.with_suffix(".json.headroom-backup")
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert backup_file.exists(), "backup must be created before overwriting"
|
||||
assert backup_file.read_text() == malformed, "backup must preserve original byte-for-byte"
|
||||
# The config file is now valid JSON with headroom provider.
|
||||
config = json.loads(config_file.read_text())
|
||||
assert "headroom" in config.get("provider", {})
|
||||
|
||||
|
||||
def test_wrap_opencode_handles_empty_config_file(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Wrap handles an empty opencode.json file gracefully."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text("")
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
|
||||
|
||||
|
||||
def test_wrap_opencode_handles_config_dir_missing(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Wrap creates the config directory when it doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_dir = tmp_path / ".config" / "opencode"
|
||||
assert not config_dir.exists()
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert config_dir.exists()
|
||||
assert (config_dir / "opencode.json").exists()
|
||||
|
||||
|
||||
def test_wrap_opencode_rtk_preserves_existing_agents_md(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""RTK injection appends to AGENTS.md without removing existing content."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
existing_content = "# My custom rules\nUse spaces, not tabs."
|
||||
(tmp_path / "AGENTS.md").write_text(existing_content)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
content = (tmp_path / "AGENTS.md").read_text()
|
||||
assert existing_content in content
|
||||
assert wrap_mod._RTK_MARKER in content
|
||||
|
||||
|
||||
def test_wrap_opencode_no_rtk_leaves_agents_md_untouched(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`--no-rtk` flag leaves existing AGENTS.md untouched."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
existing_content = "# My custom rules\nUse spaces, not tabs."
|
||||
(tmp_path / "AGENTS.md").write_text(existing_content)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, ["wrap", "opencode", "--port", "9000", "--no-rtk", "--no-mcp"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
content = (tmp_path / "AGENTS.md").read_text()
|
||||
assert content == existing_content, "--no-rtk modified AGENTS.md"
|
||||
assert wrap_mod._RTK_MARKER not in content
|
||||
|
||||
|
||||
def test_wrap_opencode_respects_opencode_config_env(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""OPENCODE_CONFIG env var overrides the default config path."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
custom_config = tmp_path / "custom" / "config.json"
|
||||
monkeypatch.setenv("OPENCODE_CONFIG", str(custom_config))
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert custom_config.exists()
|
||||
default_config = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
assert not default_config.exists(), "default config should not be created when OPENCODE_CONFIG is set"
|
||||
|
||||
|
||||
def test_wrap_opencode_headroom_project_from_cwd(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""HEADROOM_PROJECT is set based on the current working directory name."""
|
||||
project_dir = tmp_path / "my-project"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
monkeypatch.delenv("HEADROOM_PROJECT", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert env.get("HEADROOM_PROJECT") == "my-project"
|
||||
|
||||
|
||||
def test_wrap_opencode_respects_existing_headroom_project(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User-set HEADROOM_PROJECT env var is preserved, not overridden."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("HEADROOM_PROJECT", "user-set-value")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert env["HEADROOM_PROJECT"] == "user-set-value"
|
||||
|
||||
|
||||
def test_wrap_opencode_config_merges_existing_model(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Wrap preserves the user's existing model selection."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text('{"model": "openai/gpt-4o"}')
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["model"] == "openai/gpt-4o"
|
||||
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases — unwrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unwrap_opencode_removes_config_when_only_headroom_content(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unwrap removes the config file entirely when it contained only Headroom content."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
wrapped_content = (
|
||||
wrap_mod._PROVIDER_MARKER_START
|
||||
+ '\n"provider": {},\n'
|
||||
+ wrap_mod._PROVIDER_MARKER_END
|
||||
)
|
||||
config_file.write_text(wrapped_content)
|
||||
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
result = runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Removed" in result.output
|
||||
assert not config_file.exists()
|
||||
|
||||
|
||||
def test_unwrap_opencode_noop_when_config_missing(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unwrap is a safe no-op when the config file doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
result = runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "does not exist" in result.output
|
||||
|
||||
|
||||
def test_unwrap_opencode_noop_when_no_headroom_markers(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unwrap is a safe no-op when the config has no Headroom markers."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text('{"model": "openai/gpt-4o"}')
|
||||
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
result = runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "no Headroom wrap markers" in result.output
|
||||
assert config_file.read_text().strip() == '{"model": "openai/gpt-4o"}'
|
||||
|
||||
|
||||
def test_wrap_unwrap_rewrap_is_idempotent(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Full wrap-unwrap-rewrap cycle produces consistent results."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
user_config = '{"model": "openai/gpt-4o", "provider": {"openai": {}}}'
|
||||
config_file.write_text(user_config)
|
||||
|
||||
# First wrap
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
||||
|
||||
# Unwrap
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
# After unwrap, file should match original
|
||||
after_unwrap = json.loads(config_file.read_text())
|
||||
assert after_unwrap["model"] == "openai/gpt-4o"
|
||||
assert "headroom" not in after_unwrap.get("provider", {})
|
||||
|
||||
# Re-wrap
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
runner.invoke(main, ["wrap", "opencode", "--port", "9001", "--no-mcp"])
|
||||
|
||||
# After re-wrap, headroom should be back, model unchanged
|
||||
after_rewrap = json.loads(config_file.read_text())
|
||||
assert after_rewrap["model"] == "openai/gpt-4o"
|
||||
assert "headroom" in after_rewrap.get("provider", {})
|
||||
|
||||
|
||||
def test_unwrap_opencode_restores_backup_and_removes_it(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Unwrap removes the backup file after successful restore."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
backup_file = config_file.with_suffix(".json.headroom-backup")
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = '{"model": "openai/gpt-4o"}'
|
||||
config_file.write_text(original)
|
||||
backup_file.write_text(original)
|
||||
|
||||
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
||||
result = runner.invoke(main, ["unwrap", "opencode"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Restored prior" in result.output
|
||||
assert not backup_file.exists(), "backup file was not cleaned up after restore"
|
||||
|
||||
|
||||
def test_wrap_opencode_no_arguments_is_valid(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`headroom wrap opencode` with no additional arguments is a valid command."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(main, ["wrap", "opencode", "--no-mcp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["tool_label"] == "OPENCODE"
|
||||
assert captured["args"] == ()
|
||||
|
||||
|
||||
def test_wrap_opencode_with_memory_flag(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""--memory flag is accepted and does not crash."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, ["wrap", "opencode", "--port", "9000", "--memory", "--no-mcp"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def test_wrap_opencode_with_backend_and_anyllm_provider(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""--backend and --anyllm-provider flags are accepted."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, [
|
||||
"wrap", "opencode", "--port", "9000",
|
||||
"--backend", "anyllm", "--anyllm-provider", "groq",
|
||||
"--no-mcp",
|
||||
]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def test_wrap_opencode_with_no_proxy(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""--no-proxy flag skips proxy startup but still configures the tool."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, ["wrap", "opencode", "--port", "9000", "--no-proxy", "--no-mcp"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def test_wrap_opencode_with_verbose_flag(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""--verbose flag does not crash."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, ["wrap", "opencode", "--port", "9000", "--verbose", "--no-mcp"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def test_wrap_opencode_respects_opencode_home_env(
|
||||
runner: CliRunner,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""OPENCODE_HOME env var controls where AGENTS.md is written."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
||||
custom_home = str(tmp_path / "custom-opencode-home")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OPENCODE_HOME", custom_home)
|
||||
|
||||
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
||||
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
||||
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
||||
result = runner.invoke(
|
||||
main, ["wrap", "opencode", "--port", "9000", "--no-mcp"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
agents_md = Path(custom_home) / "AGENTS.md"
|
||||
assert agents_md.exists()
|
||||
|
|
@ -509,6 +509,7 @@ def test_ensure_proxy_restarts_for_flags_when_no_other_wrapper(monkeypatch) -> N
|
|||
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
||||
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
||||
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [])
|
||||
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
||||
monkeypatch.setattr(
|
||||
wrap_cli,
|
||||
"_kill_proxy_by_pid",
|
||||
|
|
|
|||
|
|
@ -66,3 +66,4 @@ def test_env_target_and_config_paths(monkeypatch, tmp_path: Path) -> None:
|
|||
assert install_paths.claude_settings_path() == tmp_path / ".claude" / "settings.json"
|
||||
assert install_paths.codex_config_path() == tmp_path / ".codex" / "config.toml"
|
||||
assert install_paths.openclaw_config_path() == tmp_path / ".openclaw" / "openclaw.json"
|
||||
assert install_paths.opencode_config_path() == tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ from headroom.providers.codex.install import apply_provider_scope as apply_codex
|
|||
from headroom.providers.codex.install import build_install_env as build_codex_install_env
|
||||
from headroom.providers.codex.install import revert_provider_scope as revert_codex_provider_scope
|
||||
from headroom.providers.copilot.install import build_install_env as build_copilot_install_env
|
||||
from headroom.providers.opencode.install import apply_provider_scope as apply_opencode_provider_scope
|
||||
from headroom.providers.opencode.install import build_install_env as build_opencode_install_env
|
||||
from headroom.providers.opencode.install import revert_provider_scope as revert_opencode_provider_scope
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> DeploymentManifest:
|
||||
|
|
@ -487,6 +490,88 @@ def test_revert_claude_provider_scope_ignores_missing_settings_file(tmp_path: Pa
|
|||
revert_claude_provider_scope(mutation, manifest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenCode provider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_opencode_build_install_env_leaves_provider_env_unset() -> None:
|
||||
env = build_opencode_install_env(port=5566, backend="ignored")
|
||||
assert env == {}
|
||||
|
||||
|
||||
def test_apply_and_revert_opencode_provider_scope(monkeypatch, tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "opencode.json"
|
||||
config_path.write_text('{"model": "openai/gpt-4o"}')
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.opencode.install.opencode_config_path", lambda: config_path
|
||||
)
|
||||
manifest = _manifest(tmp_path)
|
||||
|
||||
mutation = apply_opencode_provider_scope(manifest)
|
||||
assert mutation is not None
|
||||
assert mutation.target == "opencode"
|
||||
assert mutation.kind == "json-block"
|
||||
|
||||
content = config_path.read_text()
|
||||
data = json.loads(content)
|
||||
assert data["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
assert data["model"] == "openai/gpt-4o" # user model preserved
|
||||
|
||||
revert_opencode_provider_scope(mutation, manifest)
|
||||
reverted = json.loads(config_path.read_text())
|
||||
assert reverted["model"] == "openai/gpt-4o"
|
||||
assert "headroom" not in reverted.get("provider", {})
|
||||
|
||||
|
||||
def test_apply_opencode_provider_scope_skips_non_provider_scope(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_path = tmp_path / "opencode.json"
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.opencode.install.opencode_config_path", lambda: config_path
|
||||
)
|
||||
manifest = _manifest(tmp_path)
|
||||
manifest.scope = "user"
|
||||
|
||||
mutation = apply_opencode_provider_scope(manifest)
|
||||
assert mutation is None
|
||||
assert not config_path.exists()
|
||||
|
||||
|
||||
def test_apply_opencode_provider_scope_creates_new_config_when_missing(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_path = tmp_path / "nested" / "opencode.json"
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.opencode.install.opencode_config_path", lambda: config_path
|
||||
)
|
||||
manifest = _manifest(tmp_path)
|
||||
|
||||
mutation = apply_opencode_provider_scope(manifest)
|
||||
assert mutation is not None
|
||||
data = json.loads(config_path.read_text())
|
||||
assert data["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
|
||||
|
||||
def test_revert_opencode_provider_scope_ignores_missing_path_and_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
revert_opencode_provider_scope(
|
||||
ManagedMutation(target="opencode", kind="json-block"),
|
||||
manifest,
|
||||
)
|
||||
revert_opencode_provider_scope(
|
||||
ManagedMutation(
|
||||
target="opencode",
|
||||
kind="json-block",
|
||||
path=str(tmp_path / "missing.json"),
|
||||
),
|
||||
manifest,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 3 regression tests (#406): requires_openai_auth and openai_base_url
|
||||
# must never appear in the headroom provider block.
|
||||
|
|
@ -699,3 +784,106 @@ def test_persistent_install_strip_removes_openai_base_url(monkeypatch, tmp_path:
|
|||
f"orphaned openai_base_url must be removed by revert:\n{orphan_reverted}"
|
||||
)
|
||||
assert 'model = "gpt-4o"' in orphan_reverted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Planner-level opencode smoke tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_planner_resolves_opencode_as_install_target() -> None:
|
||||
from headroom.install.planner import resolve_targets
|
||||
|
||||
targets = resolve_targets("manual", ["opencode"])
|
||||
assert "opencode" in targets
|
||||
|
||||
|
||||
def test_planner_opencode_in_supported_targets_enum() -> None:
|
||||
from headroom.install.models import ToolTarget
|
||||
from headroom.install.planner import SUPPORTED_TARGETS, PROVIDER_SCOPE_TARGETS
|
||||
|
||||
assert ToolTarget.OPENCODE in SUPPORTED_TARGETS
|
||||
assert ToolTarget.OPENCODE in PROVIDER_SCOPE_TARGETS
|
||||
|
||||
|
||||
def test_planner_opencode_in_provider_scope_targets() -> None:
|
||||
from headroom.install.planner import resolve_targets
|
||||
|
||||
targets = resolve_targets("manual", ["opencode"], scope="provider")
|
||||
assert "opencode" in targets
|
||||
|
||||
|
||||
def test_planner_build_tool_envs_includes_opencode() -> None:
|
||||
from headroom.install.planner import build_tool_envs
|
||||
|
||||
envs = build_tool_envs(port=8787, backend="anthropic", targets=["opencode"])
|
||||
assert "opencode" in envs
|
||||
assert envs["opencode"] == {}
|
||||
|
||||
|
||||
def test_planner_resolve_all_includes_opencode() -> None:
|
||||
from headroom.install.planner import resolve_targets
|
||||
|
||||
targets = resolve_targets("all", [])
|
||||
assert "opencode" in targets
|
||||
|
||||
|
||||
def test_planner_provider_scope_unsupported_error_excludes_opencode() -> None:
|
||||
import click
|
||||
import pytest
|
||||
from headroom.install.planner import resolve_targets
|
||||
|
||||
with pytest.raises(click.ClickException, match="unsupported targets"):
|
||||
resolve_targets("manual", ["cursor"], scope="provider")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opencode revert OSError fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_revert_opencode_provider_scope_fallback_on_oserror(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""revert_opencode_provider_scope falls back to strip when backup copy fails."""
|
||||
config_path = tmp_path / "opencode.json"
|
||||
backup_path = config_path.with_suffix(".json.headroom-backup")
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from headroom.install.models import ManagedMutation
|
||||
from headroom.providers.opencode.config import (
|
||||
_PROVIDER_MARKER_START,
|
||||
_PROVIDER_MARKER_END,
|
||||
)
|
||||
original = '{"model": "openai/gpt-4o"}'
|
||||
backup_path.write_text(original)
|
||||
|
||||
provider_json = '{"headroom":{"npm":"@ai-sdk/openai-compatible","name":"Headroom Proxy","options":{"baseURL":"http://127.0.0.1:8787/v1"}}}'
|
||||
config_path.write_text(
|
||||
f'{_PROVIDER_MARKER_START}\n"provider": {provider_json},\n{_PROVIDER_MARKER_END}\n'
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.opencode.install.opencode_config_path",
|
||||
lambda: config_path,
|
||||
)
|
||||
|
||||
from headroom.providers.opencode.install import revert_provider_scope
|
||||
|
||||
manifest = _manifest(tmp_path)
|
||||
|
||||
def _fail_copy2(src, dst):
|
||||
msg = "permission denied"
|
||||
raise OSError(msg)
|
||||
|
||||
monkeypatch.setattr("shutil.copy2", _fail_copy2)
|
||||
|
||||
revert_provider_scope(
|
||||
ManagedMutation(
|
||||
target="opencode", kind="json-block", path=str(config_path)
|
||||
),
|
||||
manifest,
|
||||
)
|
||||
|
||||
assert backup_path.exists() # backup preserved when copy fails
|
||||
assert not config_path.exists() or "headroom" not in config_path.read_text()
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ class TestGreedyPathDecode:
|
|||
result = _greedy_path_decode(tmp_path, [])
|
||||
assert result == tmp_path
|
||||
|
||||
def test_empty_parts_returns_none_when_not_exists(self) -> None:
|
||||
result = _greedy_path_decode(Path("/nonexistent/path"), [])
|
||||
def test_empty_parts_returns_none_when_not_exists(self, tmp_path: Path) -> None:
|
||||
result = _greedy_path_decode(tmp_path / "missing", [])
|
||||
assert result is None
|
||||
|
||||
|
||||
|
|
|
|||
392
tests/test_mcp_registry_opencode.py
Normal file
392
tests/test_mcp_registry_opencode.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""Tests for :class:`headroom.mcp_registry.opencode.OpencodeRegistrar`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.mcp_registry.opencode import (
|
||||
OpencodeRegistrar,
|
||||
_diff_specs,
|
||||
_entry_to_spec,
|
||||
_spec_to_entry,
|
||||
_specs_equivalent,
|
||||
)
|
||||
from headroom.mcp_registry.base import RegisterStatus
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||
|
||||
|
||||
def _registrar(tmp_path: Path) -> OpencodeRegistrar:
|
||||
return OpencodeRegistrar(config_path=tmp_path / "opencode.json")
|
||||
|
||||
|
||||
def test_detect_when_binary_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Detection succeeds when the opencode binary is in PATH."""
|
||||
monkeypatch.setenv("PATH", str(tmp_path))
|
||||
(tmp_path / "opencode").write_text("#!/bin/sh\necho ok")
|
||||
(tmp_path / "opencode").chmod(0o755)
|
||||
registrar = _registrar(tmp_path)
|
||||
assert registrar.detect() is True
|
||||
|
||||
|
||||
def test_detect_when_config_dir_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Detection succeeds when the config directory exists."""
|
||||
monkeypatch.setenv("PATH", "/nonexistent")
|
||||
config_dir = tmp_path / "opencode"
|
||||
config_dir.mkdir()
|
||||
registrar = OpencodeRegistrar(config_path=config_dir / "opencode.json")
|
||||
assert registrar.detect() is True
|
||||
|
||||
|
||||
def test_detect_when_nothing_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Detection fails when neither binary nor config dir exists."""
|
||||
monkeypatch.setenv("PATH", "/nonexistent")
|
||||
registrar = OpencodeRegistrar(config_path=tmp_path / "nonexistent" / "opencode.json")
|
||||
assert registrar.detect() is False
|
||||
|
||||
|
||||
def test_get_server_returns_none_when_absent(tmp_path: Path) -> None:
|
||||
"""get_server returns None when the server is not configured."""
|
||||
registrar = _registrar(tmp_path)
|
||||
assert registrar.get_server("headroom") is None
|
||||
|
||||
|
||||
def test_get_server_returns_spec_when_present(tmp_path: Path) -> None:
|
||||
"""get_server parses the existing MCP entry correctly."""
|
||||
config = {
|
||||
"mcp": {
|
||||
"headroom": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:8787/mcp",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
_write_json(tmp_path / "opencode.json", config)
|
||||
registrar = _registrar(tmp_path)
|
||||
spec = registrar.get_server("headroom")
|
||||
assert spec is not None
|
||||
assert spec.name == "headroom"
|
||||
|
||||
|
||||
def test_register_server_creates_config_when_missing(tmp_path: Path) -> None:
|
||||
"""register_server creates the config file when it doesn't exist."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
result = registrar.register_server(spec)
|
||||
assert result.status == RegisterStatus.REGISTERED
|
||||
config_path = tmp_path / "opencode.json"
|
||||
assert config_path.exists()
|
||||
|
||||
|
||||
def test_register_server_idempotent(tmp_path: Path) -> None:
|
||||
"""register_server is a no-op when the same spec is already present."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
registrar.register_server(spec)
|
||||
result = registrar.register_server(spec)
|
||||
assert result.status == RegisterStatus.ALREADY
|
||||
|
||||
|
||||
def test_unregister_server_removes_entry(tmp_path: Path) -> None:
|
||||
"""unregister_server removes the server entry."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
registrar.register_server(spec)
|
||||
assert registrar.unregister_server("headroom") is True
|
||||
assert registrar.get_server("headroom") is None
|
||||
|
||||
|
||||
def test_unregister_server_returns_false_when_absent(tmp_path: Path) -> None:
|
||||
"""unregister_server returns False when the server was not registered."""
|
||||
registrar = _registrar(tmp_path)
|
||||
assert registrar.unregister_server("headroom") is False
|
||||
|
||||
|
||||
def test_specs_equivalent_true() -> None:
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
a = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
b = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
assert _specs_equivalent(a, b) is True
|
||||
|
||||
|
||||
def test_specs_equivalent_false() -> None:
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
a = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
b = ServerSpec(name="headroom", command="other", args=("mcp", "serve"))
|
||||
assert _specs_equivalent(a, b) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_register_server_force_overwrites_mismatch(tmp_path: Path) -> None:
|
||||
"""register_server with force=True overwrites a mismatched existing server."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec_a = ServerSpec(name="headroom", command="old", args=("serve",))
|
||||
spec_b = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
|
||||
registrar.register_server(spec_a)
|
||||
assert registrar.register_server(spec_b).status == RegisterStatus.MISMATCH
|
||||
assert registrar.register_server(spec_b, force=True).status == RegisterStatus.REGISTERED
|
||||
updated = registrar.get_server("headroom")
|
||||
assert updated is not None
|
||||
assert updated.command == "headroom"
|
||||
|
||||
|
||||
def test_unregister_removes_mcp_key_when_empty(tmp_path: Path) -> None:
|
||||
"""unregister_server removes the top-level 'mcp' key when it becomes empty."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
registrar.register_server(spec)
|
||||
assert registrar.unregister_server("headroom") is True
|
||||
assert registrar.get_server("headroom") is None
|
||||
# mcp key should be removed entirely
|
||||
import json
|
||||
data = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert "mcp" not in data
|
||||
|
||||
|
||||
def test_register_server_leaves_other_mcp_servers(tmp_path: Path) -> None:
|
||||
"""register_server preserves other MCP servers in the config."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
# Pre-populate with a user-managed MCP server
|
||||
_write_json(tmp_path / "opencode.json", {
|
||||
"mcp": {
|
||||
"existing-server": {"type": "remote", "url": "https://example.com", "enabled": True},
|
||||
}
|
||||
})
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
registrar.register_server(spec)
|
||||
|
||||
data = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert "headroom" in data["mcp"]
|
||||
assert "existing-server" in data["mcp"]
|
||||
|
||||
|
||||
def test_unregister_preserves_other_mcp_servers(tmp_path: Path) -> None:
|
||||
"""unregister_server leaves other MCP servers intact."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
_write_json(tmp_path / "opencode.json", {
|
||||
"mcp": {
|
||||
"existing-server": {"type": "remote", "url": "https://example.com"},
|
||||
}
|
||||
})
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
registrar.register_server(spec)
|
||||
registrar.unregister_server("headroom")
|
||||
|
||||
data = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert "headroom" not in data["mcp"]
|
||||
assert "existing-server" in data["mcp"]
|
||||
|
||||
|
||||
def test_get_server_returns_none_for_non_dict_mcp(tmp_path: Path) -> None:
|
||||
"""get_server returns None when 'mcp' is not a dict."""
|
||||
registrar = _registrar(tmp_path)
|
||||
_write_json(tmp_path / "opencode.json", {"mcp": "not-a-dict"})
|
||||
assert registrar.get_server("headroom") is None
|
||||
|
||||
|
||||
def test_get_server_handles_missing_config_file(tmp_path: Path) -> None:
|
||||
"""get_server returns None when the config file doesn't exist."""
|
||||
registrar = _registrar(tmp_path)
|
||||
assert registrar.get_server("headroom") is None
|
||||
|
||||
|
||||
def test_register_server_handles_config_with_no_mcp_key(tmp_path: Path) -> None:
|
||||
"""register_server adds 'mcp' key when it doesn't exist."""
|
||||
registrar = _registrar(tmp_path)
|
||||
_write_json(tmp_path / "opencode.json", {"model": "gpt-4o"})
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
registrar.register_server(spec)
|
||||
|
||||
data = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert data["model"] == "gpt-4o" # preserved
|
||||
assert "headroom" in data["mcp"]
|
||||
|
||||
|
||||
def test_register_server_with_env_vars(tmp_path: Path) -> None:
|
||||
"""register_server handles ServerSpec with environment variables."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(
|
||||
name="headroom",
|
||||
command="headroom",
|
||||
args=("mcp", "serve"),
|
||||
env={"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"},
|
||||
)
|
||||
registrar.register_server(spec)
|
||||
|
||||
data = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert data["mcp"]["headroom"]["env"] == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}
|
||||
|
||||
|
||||
def test_register_then_re_register_with_different_env_returns_mismatch(tmp_path: Path) -> None:
|
||||
"""Re-registering with different env returns MISMATCH without force."""
|
||||
registrar = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec_a = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
spec_b = ServerSpec(
|
||||
name="headroom",
|
||||
command="headroom",
|
||||
args=("mcp", "serve"),
|
||||
env={"NEW_VAR": "value"},
|
||||
)
|
||||
registrar.register_server(spec_a)
|
||||
result = registrar.register_server(spec_b)
|
||||
assert result.status == RegisterStatus.MISMATCH
|
||||
|
||||
|
||||
def test_register_server_on_malformed_config_file(tmp_path: Path) -> None:
|
||||
"""register_server overwrites a malformed config file, preserving nothing."""
|
||||
registrar = _registrar(tmp_path)
|
||||
(tmp_path / "opencode.json").write_text("not valid json at all")
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="headroom", command="headroom", args=("mcp", "serve"))
|
||||
result = registrar.register_server(spec)
|
||||
assert result.status == RegisterStatus.REGISTERED
|
||||
|
||||
data = json.loads((tmp_path / "opencode.json").read_text())
|
||||
assert "headroom" in data["mcp"]
|
||||
|
||||
|
||||
def test_entry_to_spec_command_as_string() -> None:
|
||||
"""_entry_to_spec handles a string command (not a list)."""
|
||||
entry = {
|
||||
"type": "remote",
|
||||
"command": "some-command",
|
||||
"enabled": True,
|
||||
}
|
||||
spec = _entry_to_spec("test", entry)
|
||||
assert spec.name == "test"
|
||||
assert spec.command == "some-command"
|
||||
assert spec.args == ()
|
||||
|
||||
|
||||
def test_entry_to_spec_no_command() -> None:
|
||||
"""_entry_to_spec handles an entry without a 'command' key."""
|
||||
entry: dict[str, Any] = {
|
||||
"type": "remote",
|
||||
"url": "http://example.com",
|
||||
"enabled": True,
|
||||
}
|
||||
spec = _entry_to_spec("test", entry)
|
||||
assert spec.name == "test"
|
||||
assert spec.command == ""
|
||||
|
||||
|
||||
def test_spec_to_entry_roundtrip() -> None:
|
||||
"""_spec_to_entry and _entry_to_spec are inverses for local commands."""
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
original = ServerSpec(
|
||||
name="test",
|
||||
command="python",
|
||||
args=("-m", "server"),
|
||||
env={"KEY": "VAL"},
|
||||
)
|
||||
entry = _spec_to_entry(original)
|
||||
assert entry["type"] == "remote"
|
||||
assert entry["command"] == ["python", "-m", "server"]
|
||||
assert entry["env"] == {"KEY": "VAL"}
|
||||
|
||||
restored = _entry_to_spec("test", entry)
|
||||
assert restored.name == original.name
|
||||
assert restored.command == original.command
|
||||
assert restored.args == original.args
|
||||
assert restored.env == original.env
|
||||
|
||||
|
||||
def test_diff_specs_all_fields() -> None:
|
||||
"""_diff_specs reports differences in all fields."""
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
a = ServerSpec(name="s", command="cmd_a", args=("a1",), env={"K": "A"})
|
||||
b = ServerSpec(name="s", command="cmd_b", args=("b1",), env={"K": "B"})
|
||||
diff = _diff_specs(a, b)
|
||||
assert "cmd_a" in diff
|
||||
assert "cmd_b" in diff
|
||||
assert "a1" in diff
|
||||
assert "b1" in diff
|
||||
|
||||
|
||||
def test_diff_specs_no_difference_returns_generic_message() -> None:
|
||||
"""_diff_specs returns a generic message when no identifiable field differs."""
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
a = ServerSpec(name="s", command="x")
|
||||
b = ServerSpec(name="s", command="x")
|
||||
diff = _diff_specs(a, b)
|
||||
assert "unidentified field" in diff
|
||||
|
||||
|
||||
def test_register_server_returns_already_status(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
r = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="already", command="cmd")
|
||||
r.register_server(spec)
|
||||
result = r.register_server(spec)
|
||||
assert result.status == RegisterStatus.ALREADY
|
||||
|
||||
|
||||
def test_unregister_server_handles_oserror(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
r = _registrar(tmp_path)
|
||||
from headroom.mcp_registry.base import ServerSpec
|
||||
|
||||
spec = ServerSpec(name="bad-unregister", command="cmd")
|
||||
r.register_server(spec)
|
||||
|
||||
def _fail_write(*args: Any, **kwargs: Any) -> None:
|
||||
msg = "permission denied"
|
||||
raise OSError(msg)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"headroom.mcp_registry.opencode._write_json", _fail_write
|
||||
)
|
||||
ok = r.unregister_server("bad-unregister")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_get_all_registrars_includes_opencode() -> None:
|
||||
from headroom.mcp_registry.install import get_all_registrars
|
||||
|
||||
registrars = get_all_registrars()
|
||||
names = [r.name for r in registrars]
|
||||
assert "opencode" in names
|
||||
|
|
@ -1004,6 +1004,9 @@ def _install_plugin_registry(monkeypatch, plugin):
|
|||
fake.auto_detect_plugins = lambda: [plugin] if plugin is not None else [] # type: ignore[attr-defined]
|
||||
fake.get_plugin = lambda agent_type: plugin # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "headroom.learn.registry", fake)
|
||||
import headroom.learn as learn_pkg
|
||||
|
||||
monkeypatch.setattr(learn_pkg, "registry", fake, raising=False)
|
||||
|
||||
|
||||
def _make_project(path):
|
||||
|
|
@ -1022,11 +1025,12 @@ class TestFlushToFile:
|
|||
db = tmp_path / "memory.db"
|
||||
_init_db(db)
|
||||
backend = _FakeBackend(db)
|
||||
project_path = tmp_path.resolve()
|
||||
|
||||
learner = TrafficLearner(backend=backend, agent_type="claude", min_evidence=2)
|
||||
writer = _FakeWriter()
|
||||
writer.files_to_return = [tmp_path / "CLAUDE.md"]
|
||||
proj = _make_project(str(tmp_path))
|
||||
writer.files_to_return = [project_path / "CLAUDE.md"]
|
||||
proj = _make_project(str(project_path))
|
||||
plugin = _FakePlugin(roots=[proj], writer=writer)
|
||||
_install_plugin_registry(monkeypatch, plugin)
|
||||
|
||||
|
|
@ -1038,7 +1042,7 @@ class TestFlushToFile:
|
|||
def mk() -> ExtractedPattern:
|
||||
return ExtractedPattern(
|
||||
category=PatternCategory.ENVIRONMENT,
|
||||
content=f"Use /usr/bin/python3 at {tmp_path}/main.py",
|
||||
content=f"Use /usr/bin/python3 at {project_path}/main.py",
|
||||
importance=0.6,
|
||||
)
|
||||
|
||||
|
|
@ -1138,7 +1142,8 @@ class TestFlushToFile:
|
|||
async def test_unanchored_patterns_dropped(self, tmp_path, monkeypatch):
|
||||
"""Patterns with no path anchoring are dropped before writer is called."""
|
||||
writer = _FakeWriter()
|
||||
plugin = _FakePlugin(roots=[_make_project(str(tmp_path))], writer=writer)
|
||||
project_path = tmp_path.resolve()
|
||||
plugin = _FakePlugin(roots=[_make_project(str(project_path))], writer=writer)
|
||||
_install_plugin_registry(monkeypatch, plugin)
|
||||
|
||||
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
|
||||
|
|
@ -1160,14 +1165,15 @@ class TestFlushToFile:
|
|||
"""A writer raising should be logged; flush must not bubble the error."""
|
||||
writer = _FakeWriter()
|
||||
writer.raise_on_write = True
|
||||
plugin = _FakePlugin(roots=[_make_project(str(tmp_path))], writer=writer)
|
||||
project_path = tmp_path.resolve()
|
||||
plugin = _FakePlugin(roots=[_make_project(str(project_path))], writer=writer)
|
||||
_install_plugin_registry(monkeypatch, plugin)
|
||||
|
||||
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
|
||||
learner._pattern_counts["h"] = (
|
||||
ExtractedPattern(
|
||||
category=PatternCategory.ENVIRONMENT,
|
||||
content=f"Use {tmp_path}/tool.py",
|
||||
content=f"Use {project_path}/tool.py",
|
||||
importance=0.6,
|
||||
evidence_count=2,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,9 +5,18 @@ import sys
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.models.ml_models import MLModelRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_ml_model_registry():
|
||||
MLModelRegistry.reset()
|
||||
yield
|
||||
MLModelRegistry.reset()
|
||||
|
||||
|
||||
def test_unload_many_removes_requested_keys_once(monkeypatch) -> None:
|
||||
MLModelRegistry.reset()
|
||||
registry = MLModelRegistry.get()
|
||||
|
|
|
|||
469
tests/test_providers_opencode_config.py
Normal file
469
tests/test_providers_opencode_config.py
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
"""Tests for OpenCode config file helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.providers.opencode.config import (
|
||||
HEADROOM_OPENCODE_PLUGIN,
|
||||
_inject_key_into_json,
|
||||
_parse_json_loose,
|
||||
append_headroom_plugin,
|
||||
inject_opencode_provider_config,
|
||||
opencode_config_paths,
|
||||
snapshot_opencode_config_if_unwrapped,
|
||||
strip_opencode_headroom_blocks,
|
||||
)
|
||||
|
||||
|
||||
def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
home = str(tmp_path)
|
||||
monkeypatch.setenv("HOME", home)
|
||||
monkeypatch.setenv("USERPROFILE", home)
|
||||
monkeypatch.delenv("OPENCODE_HOME", raising=False)
|
||||
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_opencode_config_paths_default(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Default config path resolves to ~/.config/opencode/opencode.json."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
config_file, backup_file = opencode_config_paths()
|
||||
assert config_file == tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
assert backup_file == tmp_path / ".config" / "opencode" / "opencode.json.headroom-backup"
|
||||
|
||||
|
||||
def test_opencode_config_paths_from_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""OPENCODE_CONFIG env var overrides the default path."""
|
||||
custom_path = tmp_path / "custom" / "opencode.json"
|
||||
monkeypatch.setenv("OPENCODE_CONFIG", str(custom_path))
|
||||
config_file, backup_file = opencode_config_paths()
|
||||
assert config_file == custom_path
|
||||
assert backup_file == tmp_path / "custom" / "opencode.json.headroom-backup"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_snapshot_creates_backup(tmp_path: Path) -> None:
|
||||
"""snapshot creates a backup copy of the config file."""
|
||||
config_file = tmp_path / "opencode.json"
|
||||
backup_file = tmp_path / "opencode.json.headroom-backup"
|
||||
config_file.write_text('{"model": "openai/gpt-4o"}')
|
||||
snapshot_opencode_config_if_unwrapped(config_file, backup_file)
|
||||
assert backup_file.exists()
|
||||
assert backup_file.read_text() == config_file.read_text()
|
||||
|
||||
|
||||
def test_snapshot_skips_if_backup_exists(tmp_path: Path) -> None:
|
||||
"""snapshot is a no-op when the backup already exists."""
|
||||
config_file = tmp_path / "opencode.json"
|
||||
backup_file = tmp_path / "opencode.json.headroom-backup"
|
||||
config_file.write_text('{"model": "a"}')
|
||||
backup_file.write_text('{"model": "b"}')
|
||||
snapshot_opencode_config_if_unwrapped(config_file, backup_file)
|
||||
assert backup_file.read_text() == '{"model": "b"}'
|
||||
|
||||
|
||||
def test_snapshot_skips_if_markers_present(tmp_path: Path) -> None:
|
||||
"""snapshot skips if the config already contains Headroom markers."""
|
||||
config_file = tmp_path / "opencode.json"
|
||||
backup_file = tmp_path / "opencode.json.headroom-backup"
|
||||
config_file.write_text('// --- Headroom proxy provider ---\n{}')
|
||||
snapshot_opencode_config_if_unwrapped(config_file, backup_file)
|
||||
assert not backup_file.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strip blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_strip_blocks_removes_provider_and_mcp() -> None:
|
||||
"""strip removes both provider and MCP blocks."""
|
||||
content = (
|
||||
"// --- Headroom proxy provider ---\n"
|
||||
'{"provider": {}}\n'
|
||||
"// --- end Headroom proxy provider ---\n"
|
||||
"// --- Headroom MCP server ---\n"
|
||||
'{"mcp": {}}\n'
|
||||
"// --- end Headroom MCP server ---\n"
|
||||
'{"model": "openai/gpt-4o"}'
|
||||
)
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
assert "Headroom" not in cleaned
|
||||
assert '{"model": "openai/gpt-4o"}' in cleaned
|
||||
|
||||
|
||||
def test_strip_blocks_preserves_user_content() -> None:
|
||||
"""strip leaves user content untouched when no blocks are present."""
|
||||
content = '{"model": "openai/gpt-4o", "provider": {"openai": {}}}'
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
assert cleaned == content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse JSON loose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_json_loose_strips_comments() -> None:
|
||||
"""_parse_json_loose ignores // comments."""
|
||||
text = '{\n "model": "gpt-4o", // default model\n "provider": {}\n}'
|
||||
data = _parse_json_loose(text)
|
||||
assert data["model"] == "gpt-4o"
|
||||
assert "provider" in data
|
||||
|
||||
|
||||
def test_parse_json_loose_returns_empty_on_invalid() -> None:
|
||||
"""_parse_json_loose returns empty dict for invalid JSON."""
|
||||
assert _parse_json_loose("not json") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inject key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_key_merges_dicts() -> None:
|
||||
"""_inject_key_into_json merges nested dicts."""
|
||||
data = {"provider": {"openai": {}}}
|
||||
data = _inject_key_into_json(data, "provider", {"headroom": {}})
|
||||
assert "openai" in data["provider"]
|
||||
assert "headroom" in data["provider"]
|
||||
|
||||
|
||||
def test_inject_key_overwrites_non_dict() -> None:
|
||||
"""_inject_key_into_json overwrites when existing value is not a dict."""
|
||||
data = {"model": "gpt-4o"}
|
||||
data = _inject_key_into_json(data, "model", "headroom/claude-sonnet-4-6")
|
||||
assert data["model"] == "headroom/claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inject provider config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_provider_config_creates_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""inject_opencode_provider_config creates the config file when missing."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
inject_opencode_provider_config(port=8787)
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
assert config_file.exists()
|
||||
config = _parse_json_loose(config_file.read_text())
|
||||
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
|
||||
assert "model" not in config # headroom provider is a transparent pass-through
|
||||
|
||||
|
||||
def test_inject_provider_config_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""inject_opencode_provider_config is safe to call multiple times."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
inject_opencode_provider_config(port=8787)
|
||||
inject_opencode_provider_config(port=9999)
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config = _parse_json_loose(config_file.read_text())
|
||||
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9999/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases — JSON parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_json_loose_handles_valid_json() -> None:
|
||||
"""_parse_json_loose returns correct dict for valid JSON without comments."""
|
||||
data = _parse_json_loose('{"model": "gpt-4o", "key": "value"}')
|
||||
assert data == {"model": "gpt-4o", "key": "value"}
|
||||
|
||||
|
||||
def test_parse_json_loose_handles_jsonc_with_comments() -> None:
|
||||
"""_parse_json_loose strips comments and returns valid data."""
|
||||
text = (
|
||||
'{\n'
|
||||
' "model": "gpt-4o",\n'
|
||||
' // this is a comment\n'
|
||||
' "provider": {}\n'
|
||||
'}'
|
||||
)
|
||||
data = _parse_json_loose(text)
|
||||
assert data["model"] == "gpt-4o"
|
||||
assert data["provider"] == {}
|
||||
|
||||
|
||||
def test_parse_json_loose_handles_urls_in_json() -> None:
|
||||
"""_parse_json_loose does NOT corrupt URLs containing //."""
|
||||
text = '{"baseURL": "http://127.0.0.1:8787/v1"}'
|
||||
data = _parse_json_loose(text)
|
||||
assert data["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
|
||||
|
||||
def test_parse_json_loose_handles_comments_and_urls() -> None:
|
||||
"""_parse_json_loose handles both comments and URLs in the same file."""
|
||||
text = (
|
||||
'{\n'
|
||||
' // proxy configuration\n'
|
||||
' "baseURL": "http://127.0.0.1:8787/v1",\n'
|
||||
' "name": "Headroom // Proxy"\n'
|
||||
'}'
|
||||
)
|
||||
data = _parse_json_loose(text)
|
||||
assert data["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
assert data["name"] == "Headroom // Proxy"
|
||||
|
||||
|
||||
def test_parse_json_loose_returns_empty_on_empty_string() -> None:
|
||||
"""_parse_json_loose returns {} for empty input."""
|
||||
assert _parse_json_loose("") == {}
|
||||
|
||||
|
||||
def test_parse_json_loose_returns_empty_on_whitespace() -> None:
|
||||
"""_parse_json_loose returns {} for whitespace-only input."""
|
||||
assert _parse_json_loose(" \n \t ") == {}
|
||||
|
||||
|
||||
def test_parse_json_loose_returns_empty_on_trailing_comma() -> None:
|
||||
"""_parse_json_loose returns {} for malformed JSON (trailing comma)."""
|
||||
assert _parse_json_loose('{"model": "gpt-4o",}') == {}
|
||||
|
||||
|
||||
def test_parse_json_loose_returns_empty_on_unclosed_brace() -> None:
|
||||
"""_parse_json_loose returns {} for malformed JSON (unclosed brace)."""
|
||||
assert _parse_json_loose('{"model": "gpt-4o"') == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases — strip blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_strip_blocks_handles_empty_string() -> None:
|
||||
"""strip_opencode_headroom_blocks returns empty string for empty input."""
|
||||
assert strip_opencode_headroom_blocks("") == ""
|
||||
|
||||
|
||||
def test_strip_blocks_handles_whitespace_only() -> None:
|
||||
"""strip_opencode_headroom_blocks returns empty string for whitespace input."""
|
||||
assert strip_opencode_headroom_blocks(" \n ") == ""
|
||||
|
||||
|
||||
def test_strip_blocks_preserves_non_headroom_jsonc() -> None:
|
||||
"""strip_opencode_headroom_blocks preserves JSONC comments not from Headroom."""
|
||||
content = (
|
||||
'// user comment\n'
|
||||
'{"model": "gpt-4o"}\n'
|
||||
'// another user comment\n'
|
||||
)
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
assert '// user comment' in cleaned
|
||||
assert '{"model": "gpt-4o"}' in cleaned
|
||||
|
||||
|
||||
def test_strip_blocks_removes_only_one_of_two_identical_blocks() -> None:
|
||||
"""strip_opencode_headroom_blocks removes all provider blocks, not just the first."""
|
||||
from headroom.providers.opencode.config import _PROVIDER_MARKER_END, _PROVIDER_MARKER_START
|
||||
content = (
|
||||
_PROVIDER_MARKER_START + "\nblock1\n" + _PROVIDER_MARKER_END + "\n"
|
||||
+ _PROVIDER_MARKER_START + "\nblock2\n" + _PROVIDER_MARKER_END
|
||||
)
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
assert _PROVIDER_MARKER_START not in cleaned
|
||||
assert "block1" not in cleaned
|
||||
assert "block2" not in cleaned
|
||||
|
||||
|
||||
def test_strip_blocks_handles_only_mcp_markers() -> None:
|
||||
"""strip_opencode_headroom_blocks also strips MCP markers."""
|
||||
from headroom.providers.opencode.config import _MCP_MARKER_END, _MCP_MARKER_START
|
||||
content = (
|
||||
_MCP_MARKER_START + "\nmcp data\n" + _MCP_MARKER_END
|
||||
)
|
||||
cleaned = strip_opencode_headroom_blocks(content)
|
||||
assert _MCP_MARKER_START not in cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases — inject config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_provider_config_merges_with_existing_mcp(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""inject_opencode_provider_config merges headroom MCP with existing MCP servers."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text('{"mcp": {"existing-server": {"type": "remote", "url": "https://example.com"}}}')
|
||||
|
||||
inject_opencode_provider_config(port=8787)
|
||||
|
||||
config = json.loads(config_file.read_text())
|
||||
assert "existing-server" in config["mcp"]
|
||||
assert "headroom" in config["mcp"]
|
||||
|
||||
|
||||
def test_inject_provider_config_idempotent_with_complex_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""inject_opencode_provider_config is idempotent on complex configs."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text(json.dumps({
|
||||
"model": "openai/gpt-4o",
|
||||
"provider": {"openai": {"models": {"gpt-4o": {}}}},
|
||||
"mcp": {"myserver": {"type": "local", "command": ["echo"]}},
|
||||
}))
|
||||
|
||||
inject_opencode_provider_config(port=8787)
|
||||
inject_opencode_provider_config(port=8787)
|
||||
|
||||
config = json.loads(config_file.read_text())
|
||||
assert "openai" in config["provider"]
|
||||
assert "headroom" in config["provider"]
|
||||
assert "myserver" in config["mcp"]
|
||||
assert "headroom" in config["mcp"]
|
||||
|
||||
|
||||
def test_inject_provider_config_preserves_unrelated_top_level_keys(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""inject_opencode_provider_config preserves top-level keys like plugin, permission, etc."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text(json.dumps({
|
||||
"plugin": ["some-plugin"],
|
||||
"permission": {"bash": {"*": "ask"}},
|
||||
"model": "openai/gpt-4o",
|
||||
}))
|
||||
|
||||
inject_opencode_provider_config(port=8787)
|
||||
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["plugin"] == ["some-plugin"]
|
||||
assert config["permission"] == {"bash": {"*": "ask"}}
|
||||
assert "headroom" in config.get("provider", {})
|
||||
|
||||
|
||||
def test_append_headroom_plugin_adds_plugin_once() -> None:
|
||||
config: dict[str, object] = {"plugin": ["some-plugin"]}
|
||||
|
||||
assert append_headroom_plugin(config) is True
|
||||
assert append_headroom_plugin(config) is False
|
||||
|
||||
assert config["plugin"] == ["some-plugin", HEADROOM_OPENCODE_PLUGIN]
|
||||
|
||||
|
||||
def test_append_headroom_plugin_preserves_configured_tuple_entry() -> None:
|
||||
config: dict[str, object] = {
|
||||
"plugin": [[HEADROOM_OPENCODE_PLUGIN, {"proxyUrl": "http://127.0.0.1:8787"}]]
|
||||
}
|
||||
|
||||
assert append_headroom_plugin(config) is False
|
||||
assert config["plugin"] == [
|
||||
[HEADROOM_OPENCODE_PLUGIN, {"proxyUrl": "http://127.0.0.1:8787"}]
|
||||
]
|
||||
|
||||
|
||||
def test_inject_provider_config_no_crash_on_unwriteable_dir(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""inject_opencode_provider_config raises click.ClickException on OSError."""
|
||||
|
||||
import click as click_mod
|
||||
monkeypatch.setenv("HOME", "/nonexistent/path/that/cannot/be/created")
|
||||
try:
|
||||
inject_opencode_provider_config(port=8787)
|
||||
except click_mod.ClickException:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime module coverage: build_opencode_config_content, build_launch_env
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_opencode_config_content_without_mcp() -> None:
|
||||
from headroom.providers.opencode.runtime import build_opencode_config_content
|
||||
|
||||
config = build_opencode_config_content(port=8787, include_mcp=False)
|
||||
assert "provider" in config
|
||||
assert "mcp" not in config
|
||||
assert "model" not in config
|
||||
assert config["plugin"] == [["headroom-opencode", {"proxyUrl": "http://127.0.0.1:8787/v1"}]]
|
||||
|
||||
|
||||
def test_build_launch_env_with_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from headroom.providers.opencode.runtime import build_launch_env
|
||||
|
||||
monkeypatch.delenv("HEADROOM_PROJECT", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
|
||||
env, display = build_launch_env(
|
||||
port=8787,
|
||||
project="test-proj",
|
||||
include_mcp=False,
|
||||
)
|
||||
assert env["HEADROOM_PROJECT"] == "test-proj"
|
||||
assert "headroom-opencode" in env["OPENCODE_CONFIG_CONTENT"]
|
||||
assert "OPENAI_BASE_URL" not in env
|
||||
assert "ANTHROPIC_BASE_URL" not in env
|
||||
|
||||
|
||||
def test_build_launch_env_with_custom_environ() -> None:
|
||||
from headroom.providers.opencode.runtime import build_launch_env
|
||||
|
||||
custom = {
|
||||
"EXISTING_VAR": "keep-me",
|
||||
"OPENAI_BASE_URL": "https://deepseek.example/v1",
|
||||
"ANTHROPIC_BASE_URL": "https://anthropic.example",
|
||||
}
|
||||
env, display = build_launch_env(
|
||||
port=8787,
|
||||
environ=custom,
|
||||
include_mcp=False,
|
||||
)
|
||||
assert env["EXISTING_VAR"] == "keep-me"
|
||||
assert env["OPENAI_BASE_URL"] == "https://deepseek.example/v1"
|
||||
assert env["ANTHROPIC_BASE_URL"] == "https://anthropic.example"
|
||||
|
||||
|
||||
def test_proxy_base_url() -> None:
|
||||
from headroom.providers.opencode.runtime import proxy_base_url
|
||||
|
||||
assert proxy_base_url(9000) == "http://127.0.0.1:9000/v1"
|
||||
|
||||
|
||||
def test_inject_provider_config_strips_existing_markers(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
inject_opencode_provider_config(port=9000)
|
||||
first = config_file.read_text()
|
||||
assert "headroom" in first
|
||||
|
||||
inject_opencode_provider_config(port=9001)
|
||||
second = config_file.read_text()
|
||||
assert "headroom" in second
|
||||
assert second.count("headroom") == first.count("headroom")
|
||||
112
tests/test_providers_opencode_install.py
Normal file
112
tests/test_providers_opencode_install.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Tests for OpenCode install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.install.models import ConfigScope, DeploymentManifest
|
||||
from headroom.providers.opencode.install import (
|
||||
apply_provider_scope,
|
||||
build_install_env,
|
||||
revert_provider_scope,
|
||||
)
|
||||
|
||||
|
||||
def _manifest(port: int = 8787) -> DeploymentManifest:
|
||||
return DeploymentManifest(
|
||||
profile="test",
|
||||
preset="persistent-task",
|
||||
runtime_kind="python",
|
||||
supervisor_kind="none",
|
||||
scope=ConfigScope.PROVIDER.value,
|
||||
provider_mode="auto",
|
||||
targets=[],
|
||||
port=port,
|
||||
host="127.0.0.1",
|
||||
backend="anthropic",
|
||||
proxy_args=[],
|
||||
base_env={},
|
||||
tool_envs={},
|
||||
)
|
||||
|
||||
|
||||
def test_build_install_env() -> None:
|
||||
"""build_install_env leaves OpenCode provider env vars untouched."""
|
||||
env = build_install_env(port=8787, backend="anthropic")
|
||||
assert env == {}
|
||||
|
||||
|
||||
def test_apply_provider_scope_creates_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""apply_provider_scope creates the opencode config with headroom provider."""
|
||||
home = str(tmp_path)
|
||||
monkeypatch.setenv("HOME", home)
|
||||
monkeypatch.setenv("USERPROFILE", home)
|
||||
monkeypatch.delenv("OPENCODE_HOME", raising=False)
|
||||
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
|
||||
|
||||
manifest = _manifest(port=8787)
|
||||
mutation = apply_provider_scope(manifest)
|
||||
assert mutation is not None
|
||||
assert mutation.target == "opencode"
|
||||
assert mutation.kind == "json-block"
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
assert config_file.exists()
|
||||
import json
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1"
|
||||
|
||||
|
||||
def test_apply_provider_scope_skips_when_scope_is_not_provider(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""apply_provider_scope returns None when scope is not PROVIDER."""
|
||||
manifest = _manifest()
|
||||
manifest.scope = ConfigScope.USER.value
|
||||
result = apply_provider_scope(manifest)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_revert_provider_scope_restores_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""revert_provider_scope strips the Headroom block from the config."""
|
||||
home = str(tmp_path)
|
||||
monkeypatch.setenv("HOME", home)
|
||||
monkeypatch.setenv("USERPROFILE", home)
|
||||
monkeypatch.delenv("OPENCODE_HOME", raising=False)
|
||||
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
|
||||
|
||||
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text('{"model": "openai/gpt-4o"}')
|
||||
|
||||
from headroom.install.models import ManagedMutation
|
||||
mutation = ManagedMutation(
|
||||
target="opencode",
|
||||
kind="json-block",
|
||||
path=str(config_file),
|
||||
)
|
||||
manifest = _manifest()
|
||||
revert_provider_scope(mutation, manifest)
|
||||
assert config_file.exists()
|
||||
assert config_file.read_text().strip() == '{"model": "openai/gpt-4o"}'
|
||||
|
||||
|
||||
def test_revert_provider_scope_noop_when_file_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""revert_provider_scope is a safe no-op when the config file is gone."""
|
||||
from headroom.install.models import ManagedMutation
|
||||
mutation = ManagedMutation(
|
||||
target="opencode",
|
||||
kind="json-block",
|
||||
path=str(tmp_path / "nonexistent.json"),
|
||||
)
|
||||
manifest = _manifest()
|
||||
revert_provider_scope(mutation, manifest)
|
||||
# Should not raise
|
||||
34
uv.lock
generated
34
uv.lock
generated
|
|
@ -1449,12 +1449,12 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "headroom-ai"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ast-grep-cli" },
|
||||
{ name = "click" },
|
||||
{ name = "litellm" },
|
||||
{ name = "litellm", marker = "python_full_version < '3.14'" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
|
|
@ -1522,7 +1522,7 @@ dev = [
|
|||
{ name = "hnswlib" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "langchain-ollama" },
|
||||
{ name = "litellm" },
|
||||
{ name = "litellm", marker = "python_full_version < '3.14'" },
|
||||
{ name = "mypy" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version >= '3.11'" },
|
||||
|
|
@ -1679,8 +1679,8 @@ requires-dist = [
|
|||
{ name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.3.3,<4.0" },
|
||||
{ name = "langchain-ollama", marker = "extra == 'dev'", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-openai", marker = "extra == 'langchain'", specifier = ">=1.1.14,<2.0" },
|
||||
{ name = "litellm", specifier = ">=1.86.2,<2.0" },
|
||||
{ name = "litellm", marker = "extra == 'dev'", specifier = ">=1.86.2,<2.0" },
|
||||
{ name = "litellm", marker = "python_full_version < '3.14'", specifier = ">=1.86.2,<2.0" },
|
||||
{ name = "litellm", marker = "python_full_version < '3.14' and extra == 'dev'", specifier = ">=1.86.2,<2.0" },
|
||||
{ name = "lm-eval", extras = ["api"], marker = "extra == 'benchmark'", specifier = ">=0.4.0" },
|
||||
{ name = "magika", marker = "extra == 'proxy'", specifier = ">=0.6.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" },
|
||||
|
|
@ -2289,18 +2289,18 @@ name = "litellm"
|
|||
version = "1.88.1"
|
||||
source = { registry = "https://pypi.org/simple/" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "click" },
|
||||
{ name = "fastuuid" },
|
||||
{ name = "httpx" },
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "aiohttp", marker = "python_full_version < '3.14'" },
|
||||
{ name = "click", marker = "python_full_version < '3.14'" },
|
||||
{ name = "fastuuid", marker = "python_full_version < '3.14'" },
|
||||
{ name = "httpx", marker = "python_full_version < '3.14'" },
|
||||
{ name = "importlib-metadata", marker = "python_full_version < '3.14'" },
|
||||
{ name = "jinja2", marker = "python_full_version < '3.14'" },
|
||||
{ name = "jsonschema", marker = "python_full_version < '3.14'" },
|
||||
{ name = "openai", marker = "python_full_version < '3.14'" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.14'" },
|
||||
{ name = "python-dotenv", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tiktoken", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tokenizers", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ea/f99ececb7f22703fe120f1d8be9ffb749ec9453fbbbbbebc0d6a6b4d7864/litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5", size = 13885969, upload-time = "2026-06-09T01:06:25.192Z" }
|
||||
wheels = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue