Merge remote-tracking branch 'upstream/main' into fix/anthropic-prefix-cache-stability

# Conflicts:
#	headroom/proxy/handlers/anthropic.py
This commit is contained in:
JerrettDavis 2026-04-04 13:54:54 -05:00
commit d78fdfe02d
20 changed files with 1066 additions and 83 deletions

View file

@ -12,12 +12,31 @@ env:
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
docker:
docker-variant-tags:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- variant: ""
bake_target: runtime
- variant: nonroot
bake_target: runtime-nonroot
- variant: code
bake_target: runtime-code
- variant: code-nonroot
bake_target: runtime-code-nonroot
- variant: slim
bake_target: runtime-slim
- variant: slim-nonroot
bake_target: runtime-slim-nonroot
- variant: code-slim
bake_target: runtime-code-slim
- variant: code-slim-nonroot
bake_target: runtime-code-slim-nonroot
steps:
- uses: actions/checkout@v6
@ -34,34 +53,31 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
- name: Extract metadata (variant)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=ref,event=branch,suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }}
type=ref,event=pr,suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }}
type=semver,pattern={{version}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }}
type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }}
type=semver,pattern={{major}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }}
type=sha,prefix=${{ matrix.variant != '' && format('{0}-', matrix.variant) || 'sha-' }}
type=raw,value=${{ matrix.variant }},enable=${{ matrix.variant != '' }}
type=raw,value=latest,enable=${{ matrix.variant == '' }}
- name: Build and push
id: push
uses: docker/build-push-action@v7
- name: Build and push variant (bake)
id: bake
uses: docker/bake-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Attest build provenance
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
files: |
./docker-bake.hcl
cwd://${{ steps.meta.outputs.bake-file-tags }}
cwd://${{ steps.meta.outputs.bake-file-labels }}
targets: ${{ matrix.bake_target }}
push: true
set: |
*.cache-from=type=gha
*.cache-to=type=gha,mode=max

View file

@ -1,5 +1,8 @@
ARG PYTHON_VERSION=3.11
ARG DISTROLESS_IMAGE=gcr.io/distroless/python3-debian13
# ---- Build stage: compile native extensions, build wheel ----
FROM python:3.11-slim AS builder
FROM python:${PYTHON_VERSION}-slim AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
@ -13,34 +16,41 @@ WORKDIR /build
# Layer 1: install deps only (cached unless pyproject.toml/uv.lock change)
COPY pyproject.toml uv.lock README.md ./
# Stub package so uv can resolve the local ".[proxy]" without full source
# Stub package so uv can resolve the local extras without full source
RUN mkdir -p headroom && touch headroom/__init__.py
ARG HEADROOM_EXTRAS=proxy,code
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system ".[proxy]"
uv pip install --system ".[${HEADROOM_EXTRAS}]"
# Layer 2: copy real source, reinstall only headroom-ai (no deps)
COPY headroom/ headroom/
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --no-deps --reinstall-package headroom-ai .
# ---- Runtime stage: minimal image with only what's needed ----
FROM python:3.11-slim AS runtime
# ---- Runtime stage (python-slim): supports root/nonroot via build arg ----
FROM python:${PYTHON_VERSION}-slim AS runtime-slim-base
ARG RUNTIME_USER=nonroot
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
RUN groupadd --gid 1000 headroom && \
useradd --uid 1000 --gid headroom --create-home headroom
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin/headroom /usr/local/bin/headroom
RUN mkdir -p /data /home/headroom/.headroom && \
chown -R headroom:headroom /data /home/headroom/.headroom
RUN mkdir -p /home/nonroot /data && \
if [ "$RUNTIME_USER" = "nonroot" ]; then \
groupadd --gid 1000 nonroot && \
useradd --uid 1000 --gid nonroot --create-home nonroot && \
mkdir -p /home/nonroot/.headroom && \
chown -R nonroot:nonroot /data /home/nonroot; \
else \
mkdir -p /root/.headroom; \
fi
USER headroom
WORKDIR /home/headroom
USER ${RUNTIME_USER}
WORKDIR /home/nonroot
ENV HEADROOM_HOST=0.0.0.0 \
PYTHONUNBUFFERED=1 \
@ -50,3 +60,25 @@ EXPOSE 8787
ENTRYPOINT ["headroom", "proxy"]
CMD ["--host", "0.0.0.0", "--port", "8787"]
FROM ${DISTROLESS_IMAGE} AS runtime-slim
ARG RUNTIME_USER=nonroot
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
USER ${RUNTIME_USER}
WORKDIR /app
ENV HEADROOM_HOST=0.0.0.0 \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/usr/local/lib/python3.11/site-packages
EXPOSE 8787
ENTRYPOINT ["python3", "-m", "headroom.cli", "proxy"]
CMD ["--host", "0.0.0.0", "--port", "8787"]
# Default published image remains python-slim runtime
FROM runtime-slim-base AS runtime

View file

@ -125,6 +125,7 @@ headroom wrap claude # Starts proxy + launches Claude Code
headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
headroom wrap aider # Starts proxy + launches Aider
headroom wrap cursor # Starts proxy + prints Cursor config
headroom wrap openclaw # Installs + configures OpenClaw plugin
```
Headroom starts a proxy, points your tool at it, and compresses everything automatically.
@ -164,7 +165,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
| **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
| **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
| **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
| **OpenClaw** | ContextEngine plugin | [See OpenClaw plugin](#openclaw-plugin) |
| **OpenClaw** | One-command wrap | `headroom wrap openclaw` |
| **Claude Code** | Wrap | `headroom wrap claude` |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
@ -419,6 +420,40 @@ pip install "headroom-ai[langchain]" # LangChain (experimental)
pip install "headroom-ai[evals]" # Evaluation framework only
```
### Container images (GHCR tags)
- supported platforms: `linux/amd64`, `linux/arm64`
- tags `:code` - image with Code-Aware Compression (AST-based) i.e. `pip install "headroom-ai[proxy,code]"`
- tags `:slim` - image with distorless base
| Tag | | Extras | Docker Bake target |
|---------------------|------------------------------------------------------|--------------|-----------------------------|
| `<version>` | ```ghcr.io/chopratejas/headroom:<version>``` | `proxy` | `runtime` |
| `latest` | ```ghcr.io/chopratejas/headroom:latest``` | `proxy` | `runtime` |
| `nonroot` | ```ghcr.io/chopratejas/headroom:nonroot``` | `proxy` | `runtime-nonroot` |
| `code` | ```ghcr.io/chopratejas/headroom:code``` | `proxy,code` | `runtime-code` |
| `code-nonroot` | ```ghcr.io/chopratejas/headroom:code-nonroot``` | `proxy,code` | `runtime-code-nonroot` |
| `slim` | ```ghcr.io/chopratejas/headroom:slim``` | `proxy` | `runtime-slim` |
| `slim-nonroot` | ```ghcr.io/chopratejas/headroom:slim-nonroot``` | `proxy` | `runtime-slim-nonroot` |
| `code-slim` | ```ghcr.io/chopratejas/headroom:code-slim``` | `proxy,code` | `runtime-code-slim` |
| `code-slim-nonroot` | ```ghcr.io/chopratejas/headroom:code-slim-nonroot``` | `proxy,code` | `runtime-code-slim-nonroot` |
### Docker Bake
```bash
# List all available build targets
docker buildx bake --list targets
# Build default image locally (proxy + nonroot)
docker buildx bake runtime-default
# Build one variant and load to local Docker image store
docker buildx bake runtime-code-slim-nonroot \
--set runtime-code-slim-nonroot.platform=linux/amd64 \
--set runtime-code-slim-nonroot.tags=headroom:local \
--load
```
Python 3.10+
---

88
docker-bake.hcl Normal file
View file

@ -0,0 +1,88 @@
target "docker-metadata-action" {}
target "_common" {
context = "."
dockerfile = "Dockerfile"
platforms = ["linux/amd64", "linux/arm64"]
}
target "runtime-default" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy"
RUNTIME_USER = "nonroot"
}
}
target "runtime" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy"
RUNTIME_USER = "root"
}
}
target "runtime-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy"
RUNTIME_USER = "nonroot"
}
}
target "runtime-code" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy,code"
RUNTIME_USER = "root"
}
}
target "runtime-code-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy,code"
RUNTIME_USER = "nonroot"
}
}
target "runtime-slim" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy"
RUNTIME_USER = "root"
}
}
target "runtime-slim-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy"
RUNTIME_USER = "nonroot"
}
}
target "runtime-code-slim" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy,code"
RUNTIME_USER = "root"
}
}
target "runtime-code-slim-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy,code"
RUNTIME_USER = "nonroot"
}
}

View file

@ -98,6 +98,7 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
headroom wrap codex # OpenAI Codex CLI
headroom wrap aider # Aider
headroom wrap cursor # Cursor
headroom wrap openclaw # OpenClaw plugin bootstrap
```
Starts the proxy, points your tool at it, compresses everything automatically.
@ -215,7 +216,7 @@ npm install headroom-ai
ContextEngine plugin for OpenClaw agents. Auto-compresses context in `assemble()`.
```bash
openclaw plugins install headroom-openclaw
headroom wrap openclaw
```
[OpenClaw Plugin &rarr;](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw)

View file

@ -5,6 +5,7 @@ Usage:
headroom wrap codex # Start proxy + OpenAI Codex CLI
headroom wrap aider # Start proxy + aider
headroom wrap cursor # Start proxy + print Cursor config instructions
headroom wrap openclaw # Install + configure OpenClaw plugin
headroom wrap claude --no-rtk # Without rtk hooks
headroom wrap claude --port 9999 # Custom proxy port
headroom wrap claude -- --model opus # Pass args to claude
@ -13,6 +14,7 @@ Usage:
from __future__ import annotations
import io
import json
import os
import shutil
import signal
@ -384,6 +386,73 @@ def _launch_tool(
cleanup()
def _run_checked(
cmd: list[str],
*,
cwd: Path | None = None,
action: str,
) -> subprocess.CompletedProcess[str]:
"""Run subprocess and raise a ClickException with actionable context on failure."""
try:
return subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError as e:
raise click.ClickException(f"{action} failed: command not found: {cmd[0]}") from e
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
details = stderr or stdout or f"exit code {e.returncode}"
raise click.ClickException(f"{action} failed: {details}") from e
def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path:
"""Resolve OpenClaw extension root from active config file path."""
result = _run_checked([openclaw_bin, "config", "file"], action="openclaw config file")
lines = result.stdout.strip().splitlines()
config_path_str = lines[-1].strip() if lines else ""
if not config_path_str:
raise click.ClickException(
"Unable to resolve OpenClaw config path from `openclaw config file`."
)
config_path = Path(config_path_str).expanduser()
return config_path.parent / "extensions"
def _copy_openclaw_plugin_into_extensions(
*,
plugin_dir: Path,
openclaw_bin: str,
) -> Path:
"""Fallback install path when `openclaw plugins install` is blocked on linked source."""
dist_dir = plugin_dir / "dist"
if not dist_dir.exists():
raise click.ClickException(
f"Plugin dist folder missing at {dist_dir}. Build the plugin first."
)
extensions_dir = _resolve_openclaw_extensions_dir(openclaw_bin)
target_dir = extensions_dir / "headroom"
target_dist = target_dir / "dist"
target_dir.mkdir(parents=True, exist_ok=True)
if target_dist.exists():
shutil.rmtree(target_dist)
shutil.copytree(dist_dir, target_dist)
for filename in ("openclaw.plugin.json", "package.json", "README.md"):
source = plugin_dir / filename
if source.exists():
shutil.copy2(source, target_dir / filename)
return target_dir
@main.group()
def wrap() -> None:
"""Wrap CLI tools to run through Headroom.
@ -398,6 +467,7 @@ def wrap() -> None:
headroom wrap codex # OpenAI Codex CLI
headroom wrap aider # Aider
headroom wrap cursor # Cursor (prints config instructions)
headroom wrap openclaw # OpenClaw plugin bootstrap
"""
@ -744,3 +814,230 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool)
raise SystemExit(1) from e
finally:
cleanup()
# =============================================================================
# OpenClaw
# =============================================================================
@wrap.command("openclaw")
@click.option(
"--plugin-path",
type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
default=None,
help="Path to local OpenClaw plugin source directory (advanced/dev override)",
)
@click.option(
"--plugin-spec",
default="headroom-ai/openclaw",
show_default=True,
help="NPM plugin spec for OpenClaw install (used when --plugin-path is omitted)",
)
@click.option(
"--skip-build",
is_flag=True,
help="Skip npm install/build in local source mode (--plugin-path)",
)
@click.option(
"--copy",
is_flag=True,
help="Install by copying plugin path instead of using --link",
)
@click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
@click.option("--startup-timeout-ms", default=20000, type=int, help="Proxy startup timeout")
@click.option(
"--python-path",
default=None,
help="Optional Python executable for proxy launcher fallback",
)
@click.option(
"--no-auto-start",
is_flag=True,
help="Disable plugin auto-start of local headroom proxy",
)
@click.option(
"--no-restart",
is_flag=True,
help="Do not restart OpenClaw gateway at the end",
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
def openclaw(
plugin_path: Path | None,
plugin_spec: str,
skip_build: bool,
copy: bool,
proxy_port: int,
startup_timeout_ms: int,
python_path: str | None,
no_auto_start: bool,
no_restart: bool,
verbose: bool,
) -> None:
"""Install and configure Headroom OpenClaw plugin in one command.
\b
What this command does:
1. Installs OpenClaw plugin from npm (or local --plugin-path)
2. Builds plugin source if --plugin-path is used
3. Writes minimal plugin config and sets contextEngine slot
4. Validates config
5. Restarts OpenClaw gateway (unless --no-restart)
\b
Example:
headroom wrap openclaw
headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw
"""
openclaw_bin = shutil.which("openclaw")
if not openclaw_bin:
raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
plugin_dir = plugin_path.resolve() if plugin_path else None
local_source_mode = plugin_dir is not None
if plugin_dir:
if not plugin_dir.exists():
raise click.ClickException(f"Plugin path not found: {plugin_dir}.")
if not (plugin_dir / "package.json").exists():
raise click.ClickException(f"Invalid plugin path (missing package.json): {plugin_dir}")
if not (plugin_dir / "openclaw.plugin.json").exists():
raise click.ClickException(
f"Invalid plugin path (missing openclaw.plugin.json): {plugin_dir}"
)
npm_bin = shutil.which("npm")
if not skip_build and not npm_bin:
raise click.ClickException(
"'npm' not found in PATH. Install Node/npm or rerun with --skip-build."
)
click.echo()
click.echo(" ╔═══════════════════════════════════════════════╗")
click.echo(" ║ HEADROOM WRAP: OPENCLAW ║")
click.echo(" ╚═══════════════════════════════════════════════╝")
click.echo()
if local_source_mode:
click.echo(f" Plugin source: local ({plugin_dir})")
else:
click.echo(f" Plugin source: npm ({plugin_spec})")
if local_source_mode and not skip_build:
click.echo(" Building OpenClaw plugin (npm install + npm run build)...")
_run_checked([npm_bin or "npm", "install"], cwd=plugin_dir, action="npm install")
_run_checked([npm_bin or "npm", "run", "build"], cwd=plugin_dir, action="npm run build")
elif not local_source_mode and skip_build:
click.echo(" Skipping build: npm install mode does not build local source.")
install_cmd = [
openclaw_bin,
"plugins",
"install",
"--dangerously-force-unsafe-install",
]
if local_source_mode:
if copy:
install_cmd.append(str(plugin_dir))
install_cwd = None
else:
install_cmd.extend(["--link", "."])
install_cwd = plugin_dir
else:
install_cmd.append(plugin_spec)
install_cwd = None
click.echo(" Installing OpenClaw plugin with required unsafe-install flag...")
install_result = subprocess.run(
install_cmd,
cwd=str(install_cwd) if install_cwd else None,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if install_result.returncode != 0:
combined_error = "\n".join(
x for x in [install_result.stderr.strip(), install_result.stdout.strip()] if x
)
plugin_already_exists = "plugin already exists" in combined_error.lower()
linked_install_bug = (
"also not a valid hook pack" in combined_error.lower()
and "--dangerously-force-unsafe-install" in " ".join(install_cmd)
)
if plugin_already_exists:
click.echo(" Plugin already installed; continuing with configuration/update steps.")
elif linked_install_bug and local_source_mode and plugin_dir is not None:
click.echo(
" OpenClaw linked-path install bug detected; applying extension-path fallback..."
)
target_dir = _copy_openclaw_plugin_into_extensions(
plugin_dir=plugin_dir,
openclaw_bin=openclaw_bin,
)
click.echo(f" Fallback plugin copy completed: {target_dir}")
else:
details = combined_error or f"exit code {install_result.returncode}"
raise click.ClickException(f"openclaw plugins install failed: {details}")
elif verbose and install_result.stdout.strip():
click.echo(install_result.stdout.strip())
plugin_config: dict[str, object] = {
"proxyPort": proxy_port,
"autoStart": not no_auto_start,
"startupTimeoutMs": startup_timeout_ms,
}
if python_path:
plugin_config["pythonPath"] = python_path
entry = {"enabled": True, "config": plugin_config}
click.echo(" Writing plugin configuration...")
_run_checked(
[
openclaw_bin,
"config",
"set",
"plugins.entries.headroom",
json.dumps(entry, separators=(",", ":")),
"--strict-json",
],
action="openclaw config set plugins.entries.headroom",
)
_run_checked(
[
openclaw_bin,
"config",
"set",
"plugins.slots.contextEngine",
json.dumps("headroom"),
"--strict-json",
],
action="openclaw config set plugins.slots.contextEngine",
)
_run_checked(
[openclaw_bin, "config", "validate"],
action="openclaw config validate",
)
if no_restart:
click.echo(" Skipping gateway restart (--no-restart).")
click.echo(" Run `openclaw gateway restart` to apply plugin changes.")
else:
click.echo(" Warning: restarting OpenClaw gateway to apply plugin changes.")
restart_result = _run_checked(
[openclaw_bin, "gateway", "restart"],
action="openclaw gateway restart",
)
if verbose and restart_result.stdout.strip():
click.echo(restart_result.stdout.strip())
inspect_result = _run_checked(
[openclaw_bin, "plugins", "inspect", "headroom"],
action="openclaw plugins inspect headroom",
)
if verbose and inspect_result.stdout.strip():
click.echo(inspect_result.stdout.strip())
click.echo()
click.echo("✓ OpenClaw is configured to use Headroom context compression.")
click.echo(" Plugin: headroom")
click.echo(" Slot: plugins.slots.contextEngine = headroom")
click.echo()

View file

@ -140,6 +140,10 @@ class ClaudeCodeWriter(ContextWriter):
def _resolve_context_path(self, project: ProjectInfo) -> Path:
if project.context_file:
return project.context_file
# If project path is the home directory, write to ~/.claude/CLAUDE.md
# (the global location Claude Code reads) instead of ~/CLAUDE.md
if project.project_path == Path.home():
return Path.home() / ".claude" / "CLAUDE.md"
return project.project_path / "CLAUDE.md"
def _resolve_memory_path(self, project: ProjectInfo) -> Path:

View file

@ -240,7 +240,7 @@ def build_session_summary(
if entry.model and "count_tokens" in entry.model:
uncompressed_reasons["passthrough"] += 1
continue
if entry.tokens_saved > 0 and entry.savings_percent > 0:
if entry.tokens_saved > 0:
compressed_requests.append(
{
"savings_pct": round(entry.savings_percent, 1),

View file

@ -143,7 +143,6 @@ class AnthropicHandlerMixin:
from headroom.proxy.helpers import (
MAX_MESSAGE_ARRAY_LENGTH,
MAX_REQUEST_BODY_SIZE,
_get_image_compressor,
_read_request_json,
)
from headroom.proxy.models import RequestLog
@ -211,6 +210,9 @@ class AnthropicHandlerMixin:
if _bypass:
logger.info(f"[{request_id}] Bypass: skipping compression (header)")
# NOTE: Upstream temporarily disabled broad image compression due to
# token-counting inaccuracies. We only compress the latest non-frozen
# user turn later in this handler to preserve Anthropic prefix caching.
# Extract headers and tags
headers = dict(request.headers.items())
headers.pop("host", None)

View file

@ -134,9 +134,15 @@ class BatchHandlerMixin:
continue
# Apply optimization
original_tokens = 0 # Set before try so error handler can use it
optimized_tokens = 0
try:
# Default context limit for most models
context_limit = 128000
# Look up model context limit, fall back to 128K
context_limit = (
self.openai_provider.get_context_limit(model)
if hasattr(self, "openai_provider")
else 128000
)
# Use OpenAI pipeline (similar message format after conversion)
result = self.openai_pipeline.apply(
@ -217,9 +223,9 @@ class BatchHandlerMixin:
logger.warning(
f"[{request_id}] Optimization failed for Google batch request {idx}: {e}"
)
# Pass through unchanged on failure
# Pass through unchanged on failure — count original as optimized
compressed_requests.append(batch_req)
total_optimized_tokens += original_tokens
total_optimized_tokens += original_tokens # 0 if pipeline never ran
# Update body with compressed requests
body["batch"]["input_config"]["requests"]["requests"] = compressed_requests

View file

@ -39,7 +39,6 @@ class OpenAIHandlerMixin:
COMPRESSION_TIMEOUT_SECONDS,
MAX_MESSAGE_ARRAY_LENGTH,
MAX_REQUEST_BODY_SIZE,
_get_image_compressor,
_read_request_json,
)
from headroom.tokenizers import get_tokenizer
@ -103,18 +102,20 @@ class OpenAIHandlerMixin:
if _bypass:
logger.info(f"[{request_id}] Bypass: skipping compression (header)")
# Image compression (before text optimization)
if self.config.image_optimize and messages and not _bypass:
compressor = _get_image_compressor()
if compressor and compressor.has_images(messages):
messages = compressor.compress(messages, provider="openai")
if compressor.last_result:
logger.info(
f"Image compression: {compressor.last_result.technique.value} "
f"({compressor.last_result.savings_percent:.0f}% saved, "
f"{compressor.last_result.original_tokens} -> "
f"{compressor.last_result.compressed_tokens} tokens)"
)
# TODO: Re-enable image compression once token counting is accurate.
# See anthropic.py handler for details on why this is disabled.
#
# if self.config.image_optimize and messages and not _bypass:
# compressor = _get_image_compressor()
# if compressor and compressor.has_images(messages):
# messages = compressor.compress(messages, provider="openai")
# if compressor.last_result:
# logger.info(
# f"Image compression: {compressor.last_result.technique.value} "
# f"({compressor.last_result.savings_percent:.0f}% saved, "
# f"{compressor.last_result.original_tokens} -> "
# f"{compressor.last_result.compressed_tokens} tokens)"
# )
headers = dict(request.headers.items())
headers.pop("host", None)
@ -497,7 +498,7 @@ class OpenAIHandlerMixin:
output_tokens = usage.get("completion_tokens", 0)
# OpenAI returns cached_tokens in prompt_tokens_details
# These are charged at 50% of the input price
prompt_details = usage.get("prompt_tokens_details", {})
prompt_details = usage.get("prompt_tokens_details") or {}
cache_read_tokens = prompt_details.get("cached_tokens", 0)
except (KeyError, TypeError, AttributeError) as e:
logger.debug(

View file

@ -80,7 +80,7 @@ class StreamingMixin:
usage["input_tokens"] = chunk_usage.get("prompt_tokens", 0)
usage["output_tokens"] = chunk_usage.get("completion_tokens", 0)
# OpenAI has cached tokens in prompt_tokens_details
details = chunk_usage.get("prompt_tokens_details", {})
details = chunk_usage.get("prompt_tokens_details") or {}
usage["cache_read_input_tokens"] = details.get("cached_tokens", 0)
elif provider == "gemini":
@ -164,7 +164,7 @@ class StreamingMixin:
if chunk_usage:
usage_found["input_tokens"] = chunk_usage.get("prompt_tokens", 0)
usage_found["output_tokens"] = chunk_usage.get("completion_tokens", 0)
details = chunk_usage.get("prompt_tokens_details", {})
details = chunk_usage.get("prompt_tokens_details") or {}
usage_found["cache_read_input_tokens"] = details.get("cached_tokens", 0)
elif provider == "gemini":

View file

@ -191,5 +191,7 @@ async def _read_request_json(request: Request) -> dict[str, Any]:
except UnicodeDecodeError as exc:
raise ValueError(f"Request body is not valid UTF-8 (possibly compressed?): {exc}") from exc
result: dict[str, Any] = json.loads(text)
result = json.loads(text)
if not isinstance(result, dict):
raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
return result

View file

@ -132,15 +132,9 @@ logging.basicConfig(
logger = logging.getLogger("headroom.proxy")
# Always-on file logging to ~/.headroom/logs/ for `headroom perf` analysis
_HEADROOM_LOG_DIR = Path.home() / ".headroom" / "logs"
_setup_file_logging()
# Maximum rate limiter buckets (prevents DoS via spoofed API keys)
MAX_RATE_LIMITER_BUCKETS = 1000
# Compression pipeline timeout in seconds

View file

@ -132,7 +132,7 @@ class BaseTokenizer(ABC):
if part_type == "text":
total += self.count_text(part.get("text", ""))
elif part_type in ("image_url", "image"):
elif part_type in ("image_url", "image", "input_image"):
# Images are NOT tokenized as text — they have a pixel-based cost.
# Anthropic: tokens = (width * height) / 750, max ~1600 after resize.
# OpenAI: similar tile-based calculation, ~765 tokens for high-detail.
@ -140,6 +140,9 @@ class BaseTokenizer(ABC):
# This prevents the base64 blob from being json.dumps'd and counted
# as text tokens (1MB image = ~330K fake tokens without this).
total += 1600
elif part_type in ("input_audio", "audio"):
# Audio has fixed token cost, not tokenized as text
total += 200
elif part_type == "tool_result":
content = part.get("content", "")
if isinstance(content, str):

View file

@ -4,6 +4,14 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw).
## Install
Recommended one-command setup:
```bash
headroom wrap openclaw
```
Manual install:
```bash
pip install "headroom-ai[proxy]"
openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw

View file

@ -54,7 +54,8 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
continue;
}
// Content blocks: extract text and tool_use blocks
// Content blocks: extract text and tool call blocks.
// OpenClaw uses `toolCall`; some adapters still emit legacy `tool_use`.
if (Array.isArray(content)) {
const textParts: string[] = [];
const toolCalls: any[] = [];
@ -64,16 +65,20 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
textParts.push(block);
} else if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "tool_use") {
} else if (block.type === "tool_use" || block.type === "toolCall") {
const args =
block.type === "toolCall"
? block.arguments
: block.input;
toolCalls.push({
id: block.id,
type: "function",
function: {
name: block.name,
arguments:
typeof block.input === "string"
? block.input
: JSON.stringify(block.input ?? {}),
typeof args === "string"
? args
: JSON.stringify(args ?? {}),
},
});
}
@ -155,11 +160,12 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
} catch {
input = tc.function.arguments ?? {};
}
// Emit OpenClaw-native block shape so downstream transports keep call linkage.
blocks.push({
type: "tool_use",
type: "toolCall",
id: tc.id,
name: tc.function.name,
input,
arguments: input,
});
}
}
@ -174,10 +180,19 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
}
if (msg.role === "tool") {
const textContent =
typeof msg.content === "string"
? msg.content
: msg.content == null
? ""
: JSON.stringify(msg.content);
const toolCallId = msg.tool_call_id ?? "unknown";
result.push({
role: "toolResult",
content: msg.content ?? "",
tool_use_id: msg.tool_call_id ?? "unknown",
// OpenClaw transport layers expect toolResult content blocks, not a raw string.
content: [{ type: "text", text: textContent }],
toolCallId,
tool_use_id: toolCallId,
timestamp: Date.now(),
});
continue;

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { openAIToAgent, type OpenAIMessage } from "../src/convert";
describe("openAIToAgent", () => {
it("emits toolResult content as blocks so transports can safely filter", () => {
const messages: OpenAIMessage[] = [
{
role: "tool",
content: "tool output",
tool_call_id: "call_123",
},
];
const result = openAIToAgent(messages);
const toolResult = result[0] as {
role: string;
content: Array<{ type: string; text?: string }>;
toolCallId: string;
tool_use_id: string;
};
expect(toolResult.role).toBe("toolResult");
expect(Array.isArray(toolResult.content)).toBe(true);
expect(toolResult.content).toEqual([{ type: "text", text: "tool output" }]);
expect(toolResult.toolCallId).toBe("call_123");
expect(toolResult.tool_use_id).toBe("call_123");
});
});

View file

@ -46,6 +46,25 @@ describe("AgentMessage conversion", () => {
expect(openai[0].tool_calls![0].function.name).toBe("search");
});
it("converts assistant with toolCall blocks", () => {
const agent = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me search" },
{ type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
],
timestamp: Date.now(),
},
];
const openai = agentToOpenAI(agent);
expect(openai[0].role).toBe("assistant");
expect(openai[0].content).toBe("Let me search");
expect(openai[0].tool_calls).toHaveLength(1);
expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1");
expect(openai[0].tool_calls![0].function.name).toBe("search");
});
it("converts toolResult message", () => {
const agent = [
{
@ -92,7 +111,7 @@ describe("AgentMessage conversion", () => {
role: "assistant",
content: [
{ type: "text", text: "Searching..." },
{ type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } },
{ type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
],
timestamp: Date.now(),
},
@ -104,7 +123,7 @@ describe("AgentMessage conversion", () => {
expect(Array.isArray(content)).toBe(true);
expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." }));
expect(content).toContainEqual(
expect.objectContaining({ type: "tool_use", id: "tu_1", name: "search" }),
expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }),
);
});
@ -120,7 +139,7 @@ describe("AgentMessage conversion", () => {
const openai = agentToOpenAI(original);
const back = openAIToAgent(openai);
expect(back[0].role).toBe("toolResult");
expect(back[0].content).toBe('{"data": true}');
expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]);
expect(back[0].tool_use_id).toBe("tu_1");
});
});

View file

@ -0,0 +1,432 @@
"""Tests for `headroom wrap openclaw` command."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from headroom.cli import wrap as wrap_cli
from headroom.cli.main import main
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def plugin_dir(tmp_path: Path) -> Path:
"""Create a minimal OpenClaw plugin directory fixture."""
plugin = tmp_path / "plugins" / "openclaw"
plugin.mkdir(parents=True)
(plugin / "package.json").write_text('{"name":"headroom-openclaw"}\n')
(plugin / "openclaw.plugin.json").write_text('{"id":"headroom"}\n')
return plugin
def _make_successful_run(calls: list[dict]) -> object:
def run(cmd, **kwargs): # noqa: ANN001
calls.append({"cmd": list(cmd), **kwargs})
return MagicMock(returncode=0, stdout="", stderr="")
return run
def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) -> None:
calls: list[dict] = []
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
result = runner.invoke(main, ["wrap", "openclaw"])
assert result.exit_code == 0, result.output
cmds = [c["cmd"] for c in calls]
assert [
"openclaw",
"plugins",
"install",
"--dangerously-force-unsafe-install",
"headroom-ai/openclaw",
] in cmds
assert ["openclaw", "config", "validate"] in cmds
assert ["openclaw", "gateway", "restart"] in cmds
assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
# Verify plugin install in npm mode does not set cwd
install_call = next(
c
for c in calls
if c["cmd"][:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
)
assert install_call["cwd"] is None
# No local build in npm mode
assert ["npm", "install"] not in cmds
assert ["npm", "run", "build"] not in cmds
# Verify config payload includes enabled + expected defaults
set_entry = next(
c
for c in calls
if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
)
payload = json.loads(set_entry["cmd"][4])
assert payload["enabled"] is True
assert payload["config"]["proxyPort"] == 8787
assert payload["config"]["autoStart"] is True
assert payload["config"]["startupTimeoutMs"] == 20000
def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
calls: list[dict] = []
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
result = runner.invoke(
main,
[
"wrap",
"openclaw",
"--plugin-path",
str(plugin_dir),
"--skip-build",
"--no-restart",
],
)
assert result.exit_code == 0, result.output
cmds = [c["cmd"] for c in calls]
assert ["npm", "install"] not in cmds
assert ["npm", "run", "build"] not in cmds
assert ["openclaw", "gateway", "restart"] not in cmds
def test_wrap_openclaw_local_source_mode_builds_and_links(
runner: CliRunner, plugin_dir: Path
) -> None:
calls: list[dict] = []
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
result = runner.invoke(
main,
["wrap", "openclaw", "--plugin-path", str(plugin_dir)],
)
assert result.exit_code == 0, result.output
cmds = [c["cmd"] for c in calls]
assert ["npm", "install"] in cmds
assert ["npm", "run", "build"] in cmds
assert [
"openclaw",
"plugins",
"install",
"--dangerously-force-unsafe-install",
"--link",
".",
] in cmds
def test_wrap_openclaw_fails_when_openclaw_missing(runner: CliRunner, plugin_dir: Path) -> None:
def which(name: str) -> str | None:
return None if name == "openclaw" else "npm"
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)])
assert result.exit_code != 0
assert "'openclaw' not found in PATH" in result.output
def test_wrap_openclaw_fails_when_plugin_path_invalid(runner: CliRunner, tmp_path: Path) -> None:
invalid = tmp_path / "missing-plugin"
with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"):
result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(invalid)])
assert result.exit_code != 0
assert "Plugin path not found" in result.output
def test_wrap_openclaw_uses_extension_fallback_on_linked_install_bug(
runner: CliRunner, plugin_dir: Path
) -> None:
calls: list[dict] = []
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
def run(cmd, **kwargs): # noqa: ANN001
calls.append({"cmd": list(cmd), **kwargs})
if cmd[:3] == ["openclaw", "plugins", "install"]:
return MagicMock(
returncode=1,
stdout="Also not a valid hook pack",
stderr='Plugin installation blocked despite "--dangerously-force-unsafe-install"',
)
return MagicMock(returncode=0, stdout="", stderr="")
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
with patch(
"headroom.cli.wrap._copy_openclaw_plugin_into_extensions",
return_value=Path("C:/Users/test/.openclaw/extensions/headroom"),
) as copy_fallback:
result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)])
assert result.exit_code == 0, result.output
copy_fallback.assert_called_once()
def test_wrap_openclaw_continues_when_plugin_already_exists(
runner: CliRunner,
) -> None:
calls: list[dict] = []
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
def run(cmd, **kwargs): # noqa: ANN001
calls.append({"cmd": list(cmd), **kwargs})
if cmd[:3] == ["openclaw", "plugins", "install"]:
return MagicMock(
returncode=1,
stdout="plugin already exists: C:\\Users\\test\\.openclaw\\extensions\\headroom",
stderr="",
)
return MagicMock(returncode=0, stdout="", stderr="")
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
result = runner.invoke(main, ["wrap", "openclaw", "--no-restart"])
assert result.exit_code == 0, result.output
cmds = [c["cmd"] for c in calls]
assert ["openclaw", "config", "validate"] in cmds
assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
def test_wrap_openclaw_verbose_prints_install_restart_and_inspect_output(
runner: CliRunner,
) -> None:
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
def run(cmd, **kwargs): # noqa: ANN001
if cmd[:3] == ["openclaw", "plugins", "install"]:
return MagicMock(returncode=0, stdout="install-ok", stderr="")
if cmd[:3] == ["openclaw", "gateway", "restart"]:
return MagicMock(returncode=0, stdout="restart-ok", stderr="")
if cmd[:3] == ["openclaw", "plugins", "inspect"]:
return MagicMock(returncode=0, stdout="inspect-ok", stderr="")
return MagicMock(returncode=0, stdout="", stderr="")
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
result = runner.invoke(main, ["wrap", "openclaw", "--verbose"])
assert result.exit_code == 0, result.output
assert "install-ok" in result.output
assert "restart-ok" in result.output
assert "inspect-ok" in result.output
def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
runner: CliRunner,
) -> None:
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
def run(cmd, **kwargs): # noqa: ANN001
if cmd[:3] == ["openclaw", "plugins", "install"]:
return MagicMock(
returncode=1,
stdout="Also not a valid hook pack",
stderr='Blocked despite "--dangerously-force-unsafe-install"',
)
return MagicMock(returncode=0, stdout="", stderr="")
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
result = runner.invoke(main, ["wrap", "openclaw"])
assert result.exit_code != 0
assert "openclaw plugins install failed" in result.output
def test_wrap_openclaw_copy_mode_uses_path_install(runner: CliRunner, plugin_dir: Path) -> None:
calls: list[dict] = []
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": "npm",
}
return mapping.get(name)
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
result = runner.invoke(
main,
[
"wrap",
"openclaw",
"--plugin-path",
str(plugin_dir),
"--copy",
"--skip-build",
"--no-restart",
],
)
assert result.exit_code == 0, result.output
cmds = [c["cmd"] for c in calls]
assert [
"openclaw",
"plugins",
"install",
"--dangerously-force-unsafe-install",
str(plugin_dir),
] in cmds
def test_wrap_openclaw_fails_when_npm_missing_for_local_build(
runner: CliRunner, plugin_dir: Path
) -> None:
def which(name: str) -> str | None:
mapping = {
"openclaw": "openclaw",
"npm": None,
}
return mapping.get(name)
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)])
assert result.exit_code != 0
assert "'npm' not found in PATH" in result.output
def test_wrap_openclaw_fails_when_local_path_missing_manifest_files(
runner: CliRunner, tmp_path: Path
) -> None:
plugin = tmp_path / "plugins" / "openclaw"
plugin.mkdir(parents=True)
with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"):
result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin)])
assert result.exit_code != 0
assert "missing package.json" in result.output
(plugin / "package.json").write_text("{}\n")
with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"):
result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin)])
assert result.exit_code != 0
assert "missing openclaw.plugin.json" in result.output
def test_run_checked_raises_click_exception_on_command_errors() -> None:
with patch("headroom.cli.wrap.subprocess.run", side_effect=FileNotFoundError()):
with pytest.raises(Exception, match="command not found"):
wrap_cli._run_checked(["missing"], action="demo")
cpe_stderr = wrap_cli.subprocess.CalledProcessError(
returncode=2,
cmd=["x"],
stderr="bad-stderr",
)
with patch("headroom.cli.wrap.subprocess.run", side_effect=cpe_stderr):
with pytest.raises(Exception, match="bad-stderr"):
wrap_cli._run_checked(["x"], action="demo")
cpe_stdout = wrap_cli.subprocess.CalledProcessError(
returncode=3,
cmd=["x"],
output="bad-stdout",
stderr="",
)
with patch("headroom.cli.wrap.subprocess.run", side_effect=cpe_stdout):
with pytest.raises(Exception, match="bad-stdout"):
wrap_cli._run_checked(["x"], action="demo")
def test_resolve_openclaw_extensions_dir_empty_output_raises() -> None:
with patch(
"headroom.cli.wrap._run_checked",
return_value=MagicMock(stdout=" \n", stderr="", returncode=0),
):
with pytest.raises(Exception, match="Unable to resolve OpenClaw config path"):
wrap_cli._resolve_openclaw_extensions_dir("openclaw")
def test_copy_openclaw_plugin_into_extensions_handles_missing_and_existing_dist(
tmp_path: Path,
) -> None:
plugin = tmp_path / "plugin"
plugin.mkdir()
with pytest.raises(Exception, match="Plugin dist folder missing"):
wrap_cli._copy_openclaw_plugin_into_extensions(plugin_dir=plugin, openclaw_bin="openclaw")
dist = plugin / "dist"
dist.mkdir()
(dist / "index.js").write_text("x\n")
(plugin / "package.json").write_text("{}\n")
(plugin / "openclaw.plugin.json").write_text("{}\n")
ext_root = tmp_path / ".openclaw" / "extensions"
target_headroom = ext_root / "headroom"
target_dist = target_headroom / "dist"
target_dist.mkdir(parents=True)
(target_dist / "old.js").write_text("old\n")
with patch("headroom.cli.wrap._resolve_openclaw_extensions_dir", return_value=ext_root):
out = wrap_cli._copy_openclaw_plugin_into_extensions(
plugin_dir=plugin, openclaw_bin="openclaw"
)
assert out == target_headroom
assert (target_dist / "index.js").exists()
assert not (target_dist / "old.js").exists()