mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge remote-tracking branch 'upstream/main' into fix/anthropic-prefix-cache-stability
# Conflicts: # headroom/proxy/handlers/anthropic.py
This commit is contained in:
commit
d78fdfe02d
20 changed files with 1066 additions and 83 deletions
70
.github/workflows/docker.yml
vendored
70
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
56
Dockerfile
56
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -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
88
docker-bake.hcl
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 →](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
28
plugins/openclaw/test/convert.test.ts
Normal file
28
plugins/openclaw/test/convert.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
432
tests/test_cli/test_wrap_openclaw.py
Normal file
432
tests/test_cli/test_wrap_openclaw.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue