chore: import upstream snapshot with attribution
CI / test (1) (push) Blocked by required conditions
CI / test (2) (push) Blocked by required conditions
CI / test (3) (push) Blocked by required conditions
CI / test (4) (push) Blocked by required conditions
CI / test-extras (push) Blocked by required conditions
CI / test-agno (push) Blocked by required conditions
CI / test-dashboard-ui (push) Blocked by required conditions
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Blocked by required conditions
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Blocked by required conditions
Docker / promote-latest (push) Blocked by required conditions
Init Native E2E / init-native (macos-latest, claude) (push) Waiting to run
Init Native E2E / init-native (macos-latest, codex) (push) Waiting to run
Init Native E2E / init-native (macos-latest, copilot) (push) Waiting to run
Install Native E2E / install-native (macos-latest) (push) Waiting to run
Wrap Native E2E / wrap-native (macos-latest) (push) Waiting to run
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / changes (push) Failing after 2s
Deploy Documentation / deploy (push) Failing after 0s
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 1s
CI / docker-native-e2e (push) Waiting to run
CI / lint (push) Waiting to run
CI / build-wheel (push) Waiting to run
CI / build-wheel-windows (push) Waiting to run
CI / prefetch-model (push) Waiting to run
CI / windows-native-wrapper (push) Waiting to run
CI / build (push) Waiting to run
CI / workflow-validation (push) Waiting to run
CI / macos-native-wrapper (push) Waiting to run
Dev Containers / validate-worktree (push) Failing after 1s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Init E2E / docker-init-e2e (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 0s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Merge Conflicts / merge-conflicts (push) Failing after 2s
OpenCode Plugin / typecheck + build + test (push) Failing after 2s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 3s
CI / test (1) (push) Blocked by required conditions
CI / test (2) (push) Blocked by required conditions
CI / test (3) (push) Blocked by required conditions
CI / test (4) (push) Blocked by required conditions
CI / test-extras (push) Blocked by required conditions
CI / test-agno (push) Blocked by required conditions
CI / test-dashboard-ui (push) Blocked by required conditions
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Blocked by required conditions
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Blocked by required conditions
Docker / promote-latest (push) Blocked by required conditions
Init Native E2E / init-native (macos-latest, claude) (push) Waiting to run
Init Native E2E / init-native (macos-latest, codex) (push) Waiting to run
Init Native E2E / init-native (macos-latest, copilot) (push) Waiting to run
Install Native E2E / install-native (macos-latest) (push) Waiting to run
Wrap Native E2E / wrap-native (macos-latest) (push) Waiting to run
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / changes (push) Failing after 2s
Deploy Documentation / deploy (push) Failing after 0s
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 1s
CI / docker-native-e2e (push) Waiting to run
CI / lint (push) Waiting to run
CI / build-wheel (push) Waiting to run
CI / build-wheel-windows (push) Waiting to run
CI / prefetch-model (push) Waiting to run
CI / windows-native-wrapper (push) Waiting to run
CI / build (push) Waiting to run
CI / workflow-validation (push) Waiting to run
CI / macos-native-wrapper (push) Waiting to run
Dev Containers / validate-worktree (push) Failing after 1s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Init E2E / docker-init-e2e (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 0s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Merge Conflicts / merge-conflicts (push) Failing after 2s
OpenCode Plugin / typecheck + build + test (push) Failing after 2s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 3s
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Default flags for act runs in this repo.
|
||||
# See https://github.com/nektos/act#configuration for all options.
|
||||
|
||||
# Use Ubuntu runner image for all jobs
|
||||
--platform=ubuntu-latest=ghcr.io/catthehacker/ubuntu:runner-latest
|
||||
|
||||
# Reuse containers so subsequent runs are faster
|
||||
--reuse
|
||||
|
||||
# Pull the image in the background if not present
|
||||
--pull=true
|
||||
|
||||
# Quiet mode (less verbose output)
|
||||
--quiet
|
||||
@@ -0,0 +1,15 @@
|
||||
# Example local act configuration.
|
||||
# Copy this file to .actrc.local and edit for your local environment.
|
||||
#
|
||||
# cp .actrc.local.example .actrc.local
|
||||
#
|
||||
# .actrc.local is NOT committed — it overrides .actrc for your machine only.
|
||||
|
||||
# Use a specific runner image (uncomment if you have issues with the default)
|
||||
# --platform=ubuntu-latest=ghcr.io/catthehacker/ubuntu:runner-latest
|
||||
|
||||
# Or use act with a specific container runtime
|
||||
# --container-args="--runtime=runc"
|
||||
|
||||
# Extra verbose output for debugging (uncomment to see all steps)
|
||||
# --verbose
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
## [0.5.26] - 2026-04-15
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "headroom-marketplace",
|
||||
"owner": {
|
||||
"name": "Headroom Contributors"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
||||
"version": "0.31.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "headroom",
|
||||
"source": "./plugins/headroom-agent-hooks",
|
||||
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
||||
"version": "0.31.0",
|
||||
"author": {
|
||||
"name": "Headroom Contributors",
|
||||
"url": "https://github.com/chopratejas/headroom"
|
||||
},
|
||||
"homepage": "https://github.com/chopratejas/headroom",
|
||||
"repository": "https://github.com/chopratejas/headroom",
|
||||
"keywords": [
|
||||
"headroom",
|
||||
"hooks",
|
||||
"claude-code",
|
||||
"copilot-cli"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"],
|
||||
"rules": {
|
||||
"body-max-line-length": [2, "always", 200],
|
||||
"footer-leading-blank": [0],
|
||||
"subject-case": [0],
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"build",
|
||||
"chore",
|
||||
"ci",
|
||||
"docs",
|
||||
"feat",
|
||||
"fix",
|
||||
"parity",
|
||||
"perf",
|
||||
"refactor",
|
||||
"revert",
|
||||
"style",
|
||||
"test"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
ARG VARIANT=3.12-bookworm
|
||||
FROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}
|
||||
|
||||
# Single-wheel architecture (post-#355): `uv sync` builds `headroom-ai`
|
||||
# from the local pyproject.toml using maturin (declared in build-system).
|
||||
# Maturin needs rust + cargo. The rustls-everywhere refactor (PR #371)
|
||||
# eliminated `openssl-sys` from our build tree, so this image no longer
|
||||
# needs `pkg-config` or `libssl-dev`.
|
||||
#
|
||||
# Rust toolchain is provisioned via the official devcontainer feature
|
||||
# (`ghcr.io/devcontainers/features/rust:1`) declared in devcontainer.json
|
||||
# so /usr/local/cargo gets the right ownership/permissions for the
|
||||
# `vscode` runtime user. A manual rustup install in this Dockerfile would
|
||||
# leave /usr/local/cargo/registry root-owned and the registry cache
|
||||
# write would fail with "Permission denied" the first time uv sync
|
||||
# triggers maturin → cargo as the vscode user.
|
||||
#
|
||||
# Drop /etc/apt/sources.list.d/yarn.list before apt-get update — the
|
||||
# base image's yarnpkg.com source has an expired GPG key that aborts
|
||||
# the whole RUN with "NO_PUBKEY 62D54FD4003F6525". The maturin build
|
||||
# doesn't need yarn.
|
||||
RUN rm -f /etc/apt/sources.list.d/yarn.list
|
||||
|
||||
RUN python -m pip install --no-cache-dir 'uv>=0.7.0' 'maturin>=1.5,<2.0'
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "Headroom",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.95.0",
|
||||
"profile": "minimal",
|
||||
"components": "rustfmt,clippy"
|
||||
}
|
||||
},
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"remoteUser": "vscode",
|
||||
"updateRemoteUserUID": true,
|
||||
"init": true,
|
||||
"containerEnv": {
|
||||
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"UV_LINK_MODE": "copy",
|
||||
"UV_PROJECT_ENVIRONMENT": "/home/vscode/.venvs/headroom"
|
||||
},
|
||||
"mounts": [
|
||||
"source=headroom-venv-${localWorkspaceFolderBasename},target=/home/vscode/.venvs,type=volume",
|
||||
"source=headroom-uv-cache,target=/home/vscode/.cache/uv,type=volume",
|
||||
"source=headroom-pip-cache,target=/home/vscode/.cache/pip,type=volume",
|
||||
"source=${localWorkspaceFolder}/../../../../../../../../,target=/workspaces-host,type=bind,consistency=cached"
|
||||
],
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"postStartCommand": "bash -lc 'git rev-parse --git-dir >/dev/null 2>&1 && git config --global --add safe.directory \"${containerWorkspaceFolder}\" || true'",
|
||||
"forwardPorts": [
|
||||
8787
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8787": {
|
||||
"label": "Headroom proxy",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"GitHub.vscode-github-actions"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"files.eol": "\n",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.defaultInterpreterPath": "/home/vscode/.venvs/headroom/bin/python",
|
||||
"python.terminal.activateEnvironment": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.pytestEnabled": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
workspace:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: sleep infinity
|
||||
environment:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
UV_LINK_MODE: copy
|
||||
UV_PROJECT_ENVIRONMENT: /home/vscode/.venvs/headroom-memory
|
||||
volumes:
|
||||
- ..:/workspaces:cached
|
||||
- ../../../../../../../../:/workspaces-host:cached
|
||||
- headroom-venv-memory:/home/vscode/.venvs
|
||||
- headroom-uv-cache:/home/vscode/.cache/uv
|
||||
- headroom-pip-cache:/home/vscode/.cache/pip
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.17.1
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
environment:
|
||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
||||
|
||||
neo4j:
|
||||
image: neo4j:5.15.0
|
||||
volumes:
|
||||
- neo4j-data:/data
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/password
|
||||
NEO4J_PLUGINS: '["apoc"]'
|
||||
NEO4J_apoc_export_file_enabled: "true"
|
||||
NEO4J_apoc_import_file_enabled: "true"
|
||||
NEO4J_apoc_import_file_use__neo4j__config: "true"
|
||||
|
||||
volumes:
|
||||
headroom-venv-memory:
|
||||
headroom-pip-cache:
|
||||
headroom-uv-cache:
|
||||
neo4j-data:
|
||||
qdrant-data:
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "Headroom (memory stack)",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.memory.yml"
|
||||
],
|
||||
"service": "workspace",
|
||||
"runServices": [
|
||||
"qdrant",
|
||||
"neo4j"
|
||||
],
|
||||
"workspaceFolder": "/workspaces",
|
||||
"remoteUser": "vscode",
|
||||
"updateRemoteUserUID": true,
|
||||
"shutdownAction": "stopCompose",
|
||||
"overrideCommand": false,
|
||||
"init": true,
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.95.0",
|
||||
"profile": "minimal",
|
||||
"components": "rustfmt,clippy"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [
|
||||
8787,
|
||||
6333,
|
||||
6334,
|
||||
7474,
|
||||
7687
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8787": {
|
||||
"label": "Headroom proxy",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"6333": {
|
||||
"label": "Qdrant REST",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"6334": {
|
||||
"label": "Qdrant gRPC",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"7474": {
|
||||
"label": "Neo4j Browser",
|
||||
"onAutoForward": "openBrowser"
|
||||
},
|
||||
"7687": {
|
||||
"label": "Neo4j Bolt",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh memory-stack",
|
||||
"postStartCommand": "bash -lc 'git rev-parse --git-dir >/dev/null 2>&1 && git config --global --add safe.directory \"${containerWorkspaceFolder}\" || true'",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"GitHub.vscode-github-actions"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"files.eol": "\n",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.defaultInterpreterPath": "/home/vscode/.venvs/headroom-memory/bin/python",
|
||||
"python.terminal.activateEnvironment": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.pytestEnabled": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
profile="${1:-default}"
|
||||
project_env="${UV_PROJECT_ENVIRONMENT:-/home/vscode/.venvs/headroom}"
|
||||
project_env_root="$(dirname "$project_env")"
|
||||
cache_root="${HOME}/.cache"
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
workspace_root="$(cd "$script_dir/.." && pwd)"
|
||||
git_profile_script="/etc/profile.d/headroom-worktree-git.sh"
|
||||
sync_extras=(--extra dev)
|
||||
|
||||
if [[ "$profile" == "memory-stack" ]]; then
|
||||
sync_extras+=(--extra memory-stack)
|
||||
fi
|
||||
|
||||
cd "$workspace_root"
|
||||
|
||||
configure_worktree_git_env() {
|
||||
sudo rm -f "$git_profile_script"
|
||||
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -f .git ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local gitdir_spec repo_root translated_git_dir translated_common_dir
|
||||
gitdir_spec="$(sed -n 's/^gitdir: //p' .git)"
|
||||
|
||||
if [[ -z "$gitdir_spec" || "$gitdir_spec" != *"/.git/worktrees/"* ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
repo_root="${gitdir_spec%/.git/worktrees/*}"
|
||||
|
||||
if [[ "$gitdir_spec" =~ ^[A-Za-z]:/ ]]; then
|
||||
translated_git_dir="/workspaces-host/${gitdir_spec#?:/}"
|
||||
translated_common_dir="/workspaces-host/${repo_root#?:/}/.git"
|
||||
elif [[ "$gitdir_spec" == /* ]]; then
|
||||
translated_git_dir="/workspaces-host${gitdir_spec}"
|
||||
translated_common_dir="/workspaces-host${repo_root}/.git"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$translated_git_dir" || ! -d "$translated_common_dir" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo tee "$git_profile_script" >/dev/null <<EOF
|
||||
export GIT_DIR="$translated_git_dir"
|
||||
export GIT_COMMON_DIR="$translated_common_dir"
|
||||
export GIT_WORK_TREE="$workspace_root"
|
||||
EOF
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$git_profile_script"
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||
}
|
||||
|
||||
sudo mkdir -p "$project_env_root" "$cache_root/uv" "$cache_root/pip" "$cache_root/pre-commit"
|
||||
sudo chown -R "$(id -u):$(id -g)" "$project_env_root" "$cache_root"
|
||||
|
||||
uv sync --frozen "${sync_extras[@]}" --link-mode copy
|
||||
|
||||
if configure_worktree_git_env; then
|
||||
uv run pre-commit install
|
||||
else
|
||||
echo "Skipping pre-commit install because git metadata is not available inside this container."
|
||||
fi
|
||||
|
||||
echo "Headroom devcontainer is ready."
|
||||
if [[ "$profile" == "memory-stack" ]]; then
|
||||
echo "Memory stack sidecars are available at qdrant:6333 and neo4j://neo4j:7687."
|
||||
fi
|
||||
echo "Run checks with:"
|
||||
echo " uv run ruff check ."
|
||||
echo " uv run ruff format --check ."
|
||||
echo " uv run mypy headroom --ignore-missing-imports"
|
||||
echo " uv run pytest -v --tb=short"
|
||||
@@ -0,0 +1,77 @@
|
||||
# VCS
|
||||
.git/*
|
||||
!.git/HEAD
|
||||
!.git/packed-refs
|
||||
!.git/refs/
|
||||
!.git/refs/**
|
||||
.github
|
||||
.github/*
|
||||
!.github/plugin/
|
||||
!.github/plugin/**
|
||||
.gitignore
|
||||
|
||||
# Python artifacts
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Dev/test tooling
|
||||
.pytest_cache
|
||||
.coverage
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.pre-commit-config.yaml
|
||||
.venv
|
||||
venv
|
||||
|
||||
# Tests & docs (not needed in image)
|
||||
tests/
|
||||
docs/
|
||||
mkdocs.yml
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
NOTICE
|
||||
|
||||
# JS/TS artifacts (dashboard, SDK — not part of proxy image)
|
||||
apps/
|
||||
sdk/
|
||||
!sdk/
|
||||
!sdk/typescript/
|
||||
!sdk/typescript/**
|
||||
plugins/
|
||||
!plugins/
|
||||
!plugins/openclaw/
|
||||
!plugins/openclaw/**
|
||||
!plugins/headroom-agent-hooks/
|
||||
!plugins/headroom-agent-hooks/**
|
||||
node_modules/
|
||||
*.tgz
|
||||
|
||||
# Secrets & local config
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
.pi-lens/
|
||||
.superpowers/
|
||||
examples/
|
||||
node-compile-cache/
|
||||
!e2e/
|
||||
!e2e/init/
|
||||
!e2e/init/**
|
||||
!.claude-plugin/
|
||||
!.claude-plugin/**
|
||||
@@ -0,0 +1,15 @@
|
||||
# Example .env file for act local testing.
|
||||
# Copy this to .env.act (which is gitignored) and fill in test values.
|
||||
#
|
||||
# cp .env.act.example .env.act
|
||||
#
|
||||
# act automatically reads .env and passes as secrets to workflows.
|
||||
# DO NOT commit .env — it contains real or test tokens.
|
||||
|
||||
# npmjs.org token (use a test token, not a real one)
|
||||
NPM_TOKEN=test_token_replace_me
|
||||
|
||||
# PyPI trusted publisher (not needed for act, but documented)
|
||||
# PYPI_TRUSTED_PUBLISHER=...
|
||||
|
||||
#dry_run=true
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copy this file to .env and fill in real values before running in production.
|
||||
# IMPORTANT: Change NEO4J_AUTH before deploying — default credentials are insecure.
|
||||
NEO4J_AUTH=neo4j/CHANGEME
|
||||
@@ -0,0 +1,10 @@
|
||||
# Revisions listed here are skipped by `git blame --ignore-revs-file`
|
||||
# and by GitHub's blame UI. Use for mechanical, repo-wide changes that
|
||||
# touch every line of a file but don't change semantics (formatter runs,
|
||||
# line-ending normalization, bulk renames applied by codemod, etc.).
|
||||
#
|
||||
# Configure your local git to auto-pick this up:
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
|
||||
# chore: renormalize line endings to LF
|
||||
efd2ac1ca4d88d8f5990259c98b673c603902896
|
||||
@@ -0,0 +1,2 @@
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,50 @@
|
||||
# GitGuardian configuration — secret-detection allowlist
|
||||
#
|
||||
# Two entries below are test fixtures, NOT real credentials. They live in
|
||||
# tests that exercise our own detectors (volatile-content scanner, auth-mode
|
||||
# classifier) — by definition we have to embed plausible-looking tokens to
|
||||
# verify the detectors recognise them. The strings are syntactically valid
|
||||
# but carry no privilege against any real service.
|
||||
#
|
||||
# Anything else GitGuardian flags should be treated as a real incident: do
|
||||
# NOT extend this allowlist without verifying the secret is unauthenticated
|
||||
# fixture data, and rotate any genuine leaks before adding the row.
|
||||
|
||||
version: 2
|
||||
|
||||
secret:
|
||||
ignored-matches:
|
||||
# Canonical fake JWT used across the JS/Python ecosystem to demonstrate
|
||||
# JWT-shaped strings. Header `{"alg":"HS256"}`, payload `{"sub":"1"}`.
|
||||
# We use it to verify our `detect_volatile_content` recogniser flags JWTs.
|
||||
# File: tests/test_cache_aligner_detector_only.py
|
||||
- name: "fake JWT in volatile-content detector test"
|
||||
match: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
# Anthropic-shaped strings whose payloads literally contain the word
|
||||
# "fixture" — used in `test_realignment_live_multi_turn` to assert the
|
||||
# auth-mode classifier routes PAYG / OAuth / subscription headers
|
||||
# correctly. No live API call is ever made with these tokens.
|
||||
# File: tests/test_realignment_live_multi_turn.py
|
||||
- name: "Anthropic-shaped fixture token (PAYG via x-api-key)"
|
||||
match: "sk-ant-api03-payg-fixture"
|
||||
- name: "Anthropic-shaped fixture token (OAuth bearer)"
|
||||
match: "sk-ant-oat01-oauth-fixture"
|
||||
- name: "Anthropic-shaped fixture token (PAYG via bearer)"
|
||||
match: "sk-ant-api03-payg-bearer-fixture"
|
||||
|
||||
# Minimal GitHub-shaped tokens used in tests/test_copilot_auth.py to
|
||||
# exercise _token_kind() prefix detection and _is_copilot_api_token().
|
||||
# Values are intentionally short/low-entropy — they carry no privilege.
|
||||
- name: "GitHub OAuth token fixture (test_copilot_auth)"
|
||||
match: "gho_x"
|
||||
- name: "GitHub Apps token fixture (test_copilot_auth)"
|
||||
match: "ghs_x"
|
||||
- name: "GitHub PAT fixture (test_copilot_auth)"
|
||||
match: "ghp_x"
|
||||
- name: "GitHub fine-grained PAT fixture (test_copilot_auth)"
|
||||
match: "github_pat_x"
|
||||
- name: "Copilot session token fixture (test_copilot_auth)"
|
||||
match: "tid_x"
|
||||
- name: "GitHub OAuth token fixture for exchange_token test"
|
||||
match: "gho_test"
|
||||
@@ -0,0 +1,11 @@
|
||||
# CODEOWNERS — default reviewers for this repository.
|
||||
# Docs: https://docs.github.com/articles/about-code-owners
|
||||
#
|
||||
# Owners listed here are auto-requested for review on matching pull requests.
|
||||
# When branch protection requires code-owner review, any one of them can
|
||||
# satisfy it. Owners must have write access to the repo or the line is ignored.
|
||||
#
|
||||
# Order matters: the last matching pattern wins.
|
||||
|
||||
# Catch-all: the maintainers own everything by default.
|
||||
* @chopratejas @JerrettDavis @DevanshiVyas
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug to help us improve Headroom
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## To Reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Install headroom with '...'
|
||||
2. Run this code '...'
|
||||
3. See error
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What actually happened.
|
||||
|
||||
## Code Sample
|
||||
|
||||
```python
|
||||
# Minimal code to reproduce the issue
|
||||
from headroom import HeadroomClient
|
||||
|
||||
# Your code here
|
||||
```
|
||||
|
||||
## Error Output
|
||||
|
||||
```
|
||||
Paste any error messages or stack traces here
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- **Headroom version**: (run `python -c "import headroom; print(headroom.__version__)"`)
|
||||
- **Python version**: (run `python --version`)
|
||||
- **OS**: (e.g., macOS 14.0, Ubuntu 22.04, Windows 11)
|
||||
- **LLM Provider**: (e.g., OpenAI, Anthropic)
|
||||
|
||||
## Additional Context
|
||||
|
||||
Add any other context about the problem here (logs, screenshots, etc.)
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions & Discussions
|
||||
url: https://github.com/chopratejas/headroom/discussions
|
||||
about: Ask questions and discuss ideas in GitHub Discussions
|
||||
- name: Documentation
|
||||
url: https://headroom-docs.vercel.app/docs
|
||||
about: Check out the documentation for guides and API reference
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: Copilot Subscription Test Report
|
||||
about: Report results of testing `headroom wrap copilot --subscription` on Linux/Windows/macOS
|
||||
title: '[COPILOT-SUB] <OS> test report'
|
||||
labels: copilot-subscription, testing
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!--
|
||||
Thanks for helping verify Copilot subscription mode across platforms!
|
||||
See TESTING-copilot-subscription.md for the step-by-step flows.
|
||||
Redact your actual token everywhere.
|
||||
-->
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS + version**: (e.g., Ubuntu 24.04, Windows 11 23H2, macOS 14.5)
|
||||
- **Architecture**: (x86_64 / arm64)
|
||||
- **How you installed headroom**: (pipx/pip `--pre` wheel · Docker install.sh/ps1 · built from source)
|
||||
- **headroom version**: (`headroom --version`)
|
||||
- **Copilot CLI version**: (`copilot --version`)
|
||||
- **Was plain `copilot` logged in before the test?**: yes / no
|
||||
|
||||
## Result
|
||||
|
||||
- **Command run**:
|
||||
```
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
|
||||
```
|
||||
- **Did it print `HEADROOM_OK`?**: yes / no
|
||||
- **Worked WITHOUT `GITHUB_COPILOT_TOKEN` (auto-discovery)?**: yes / no / didn't try
|
||||
- **Worked WITH `GITHUB_COPILOT_TOKEN` set?**: yes / no / didn't try
|
||||
|
||||
## Error output (if any)
|
||||
|
||||
```
|
||||
paste any error here
|
||||
```
|
||||
|
||||
## Token storage schema (only if auto-discovery failed)
|
||||
|
||||
Helps us fix auto-discovery. **Redact the secret value.**
|
||||
|
||||
- Linux: `secret-tool search --all 2>/dev/null | sed -E 's/^secret = .*/secret = <redacted>/'`
|
||||
- Windows: `cmd /c "cmdkey /list"` (paste the Copilot-related `Target:` line)
|
||||
- macOS (reference): service `copilot-cli`
|
||||
|
||||
```
|
||||
paste the attribute / Target lines here (secret redacted)
|
||||
```
|
||||
|
||||
## Anything else
|
||||
|
||||
(logs from `~/.headroom/logs/proxy.log`, surprises, etc.)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for Headroom
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
A clear description of the problem you're trying to solve.
|
||||
Ex: "I'm always frustrated when..."
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Describe the solution you'd like. Be as specific as possible.
|
||||
|
||||
## Use Case
|
||||
|
||||
Explain your use case and why this feature would be valuable:
|
||||
|
||||
- What type of application are you building?
|
||||
- How would this feature help you?
|
||||
- How many tokens/cost would this save?
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Describe any alternative solutions or features you've considered.
|
||||
|
||||
## Example API (Optional)
|
||||
|
||||
If you have ideas about how the API should look:
|
||||
|
||||
```python
|
||||
# How you'd like to use this feature
|
||||
from headroom import SomeNewFeature
|
||||
|
||||
# Example usage
|
||||
```
|
||||
|
||||
## Additional Context
|
||||
|
||||
- Are you willing to contribute this feature?
|
||||
- Any relevant links, papers, or prior art?
|
||||
@@ -0,0 +1,65 @@
|
||||
## Description
|
||||
|
||||
<!-- Briefly explain the change and why it is needed. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring (no functional changes)
|
||||
|
||||
## Changes Made
|
||||
|
||||
-
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Check what you actually ran, then paste the real command output below. -->
|
||||
|
||||
- [ ] Unit tests pass (`pytest`)
|
||||
- [ ] Linting passes (`ruff check .`)
|
||||
- [ ] Type checking passes (`mypy headroom`)
|
||||
- [ ] New tests added for new functionality
|
||||
- [ ] Manual testing performed
|
||||
|
||||
### Test Output
|
||||
|
||||
```text
|
||||
# Paste relevant command output or artifact links here
|
||||
```
|
||||
|
||||
## Real Behavior Proof
|
||||
|
||||
- Environment:
|
||||
- Exact command / steps:
|
||||
- Observed result:
|
||||
- Not tested:
|
||||
|
||||
## Review Readiness
|
||||
|
||||
- [ ] I have performed a self-review
|
||||
- [ ] This PR is ready for human review
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] I have updated the CHANGELOG.md if applicable
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
Add screenshots to help explain your changes.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. -->
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"inputs": {
|
||||
"version": "0.6.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"inputs": {
|
||||
"dry_run": "true"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 42,
|
||||
"pull_request": {
|
||||
"number": 42,
|
||||
"draft": false,
|
||||
"title": "feat: add PR governance",
|
||||
"body": "## Description\n\nFixes #123\n",
|
||||
"user": {
|
||||
"login": "octocat"
|
||||
},
|
||||
"base": {
|
||||
"sha": "dff6a199"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "JerrettDavis/headroom"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"action": "ready_for_review",
|
||||
"number": 42,
|
||||
"pull_request": {
|
||||
"number": 42,
|
||||
"draft": false,
|
||||
"title": "feat: add PR governance",
|
||||
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\nCloses #123\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n- Added a commit-msg hook that runs commitlint locally.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n- [x] Manual testing performed\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR with an incomplete template, then fixed the body.\n- Observed result: The governance check failed until the template and readiness boxes were complete.\n- Not tested: Repository-level automatic Copilot rulesets.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
|
||||
"user": {
|
||||
"login": "octocat"
|
||||
},
|
||||
"base": {
|
||||
"sha": "dff6a199"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "JerrettDavis/headroom"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ref": "refs/heads/main",
|
||||
"commits": [
|
||||
{
|
||||
"message": "feat: add new release automation"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ref": "refs/heads/main",
|
||||
"head_commit": {
|
||||
"message": "Merge pull request #217 from JerrettDavis/fix/openclaw-local-node-publish\n\nFix OpenClaw GPR package build"
|
||||
},
|
||||
"commits": [
|
||||
{
|
||||
"message": "Merge pull request #217 from JerrettDavis/fix/openclaw-local-node-publish\n\nFix OpenClaw GPR package build"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"action": "published",
|
||||
"release": {
|
||||
"tag_name": "v0.9.2",
|
||||
"name": "Release v0.9.2",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"body": "## What's Changed\n\n* fix: example change for act dry-run simulation"
|
||||
},
|
||||
"ref": "refs/tags/v0.9.2"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Headroom e2e setup
|
||||
description: >-
|
||||
Checkout-agnostic setup shared by native e2e workflows (init, install, wrap).
|
||||
Installs Python, optionally installs the Rust toolchain + editable headroom
|
||||
package, and (optionally) drops PATH shims for the local ``headroom`` CLI and
|
||||
target binaries so ``headroom init -g <target>`` can detect tools that aren't
|
||||
actually installed on the runner.
|
||||
inputs:
|
||||
python-version:
|
||||
description: Python version to install
|
||||
required: false
|
||||
default: "3.11"
|
||||
install-mode:
|
||||
description: >-
|
||||
Install strategy. ``editable-proxy`` builds the local package with
|
||||
``pip install -e .[proxy]`` and verifies ``headroom._core``.
|
||||
``deps-only-proxy`` installs the base + ``[proxy]`` dependency set from
|
||||
pyproject.toml, then drops a local ``headroom`` launcher that imports
|
||||
from the checkout without building the package; use this for CLI tests
|
||||
that do not exercise the Rust extension.
|
||||
required: false
|
||||
default: "editable-proxy"
|
||||
shim-target:
|
||||
description: >-
|
||||
Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave
|
||||
empty to skip shim creation.
|
||||
required: false
|
||||
default: ""
|
||||
outputs:
|
||||
shim-dir:
|
||||
description: Absolute path to the directory containing the dropped shim
|
||||
value: ${{ steps.shim.outputs.shim-dir }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
# Single-wheel architecture: `pip install -e .` invokes maturin (declared
|
||||
# in pyproject.toml's build-system) which calls cargo to compile the Rust
|
||||
# extension. Toolchain has to be set up before the editable install path.
|
||||
- name: Install Rust toolchain
|
||||
if: ${{ inputs.install-mode == 'editable-proxy' }}
|
||||
uses: dtolnay/rust-toolchain@1.95.0
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
if: ${{ inputs.install-mode == 'editable-proxy' }}
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
# macos-latest (macos-15) runners have varying Xcode versions installed.
|
||||
# The Rust cc crate probes the active Xcode for
|
||||
# .../lib/clang/<ver>/lib/darwin/libclang_rt.osx.a. Some Xcode versions
|
||||
# (notably 16.4 / clang 17 on certain runner images) lack this path.
|
||||
# 1. Find an Xcode whose clang runtime directory actually exists.
|
||||
# 2. If none found, locate libclang_rt.osx and create the expected symlink.
|
||||
- name: Fix clang_rt.osx linker path (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
found=
|
||||
for app in /Applications/Xcode_*.app; do
|
||||
[ -d "$app" ] || continue
|
||||
clang_dir=$(ls -d "$app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/"*/lib/darwin 2>/dev/null | head -1)
|
||||
if [ -n "$clang_dir" ]; then
|
||||
sudo xcode-select -s "$app"
|
||||
echo "Selected Xcode: $app (has clang runtime at $clang_dir)"
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$found" ]; then
|
||||
rt_lib=$(find /Applications -name "libclang_rt.osx*" 2>/dev/null | head -1)
|
||||
if [ -n "$rt_lib" ]; then
|
||||
xcode_ver=$(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')
|
||||
clang_ver=$(clang --version 2>/dev/null | head -1 | grep -oP 'version \K\d+')
|
||||
exp_dir="/Applications/Xcode_${xcode_ver}.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/${clang_ver}/lib/darwin"
|
||||
sudo mkdir -p "$exp_dir"
|
||||
target="$exp_dir/$(basename "$rt_lib")"
|
||||
[ -f "$target" ] || sudo ln -sf "$rt_lib" "$target"
|
||||
echo "Symlinked $rt_lib -> $target"
|
||||
else
|
||||
echo "WARNING: libclang_rt.osx not found anywhere. Build may fail."
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Install headroom (editable, with proxy extras — builds Rust extension)
|
||||
if: ${{ inputs.install-mode == 'editable-proxy' }}
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# ``headroom/cli/__init__.py`` eagerly imports ``proxy.server`` (via
|
||||
# ``cli/proxy.py``), which requires ``fastapi`` even for ``init``.
|
||||
# Install with the ``[proxy]`` extras to match the Docker e2e image.
|
||||
pip install -e ".[proxy]"
|
||||
python -c "from headroom._core import DiffCompressor; print('headroom._core OK:', DiffCompressor)"
|
||||
|
||||
- name: Install base + proxy dependencies without building headroom
|
||||
if: ${{ inputs.install-mode == 'deps-only-proxy' }}
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python - <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
requirements = list(project["project"]["dependencies"])
|
||||
requirements.extend(project["project"]["optional-dependencies"]["proxy"])
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--retries", "10", "--timeout", "60", *requirements]
|
||||
)
|
||||
PY
|
||||
python -c "from headroom.cli.main import main; print('headroom CLI OK:', main)"
|
||||
|
||||
- name: Drop local headroom launcher (POSIX)
|
||||
if: ${{ inputs.install-mode == 'deps-only-proxy' && runner.os != 'Windows' }}
|
||||
shell: bash
|
||||
run: |
|
||||
shim_dir="${RUNNER_TEMP}/headroom-local-bin"
|
||||
mkdir -p "$shim_dir"
|
||||
cat > "$shim_dir/headroom" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exec python -m headroom.cli "$@"
|
||||
SH
|
||||
chmod +x "$shim_dir/headroom"
|
||||
echo "$shim_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Drop local headroom launcher (Windows)
|
||||
if: ${{ inputs.install-mode == 'deps-only-proxy' && runner.os == 'Windows' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$shimDir = Join-Path $env:RUNNER_TEMP "headroom-local-bin"
|
||||
New-Item -ItemType Directory -Force -Path $shimDir | Out-Null
|
||||
@"
|
||||
@echo off
|
||||
python -m headroom.cli %*
|
||||
"@ | Out-File -FilePath (Join-Path $shimDir "headroom.cmd") -Encoding ascii
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $shimDir
|
||||
|
||||
- name: Drop shim (POSIX)
|
||||
if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }}
|
||||
id: shim-posix
|
||||
shell: bash
|
||||
run: |
|
||||
shim_dir="${RUNNER_TEMP}/headroom-e2e-shims"
|
||||
bash e2e/_lib/make_shim.sh "${{ inputs.shim-target }}" "$shim_dir"
|
||||
echo "$shim_dir" >> "$GITHUB_PATH"
|
||||
echo "shim-dir=$shim_dir" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Drop shim (Windows)
|
||||
if: ${{ inputs.shim-target != '' && runner.os == 'Windows' }}
|
||||
id: shim-windows
|
||||
shell: pwsh
|
||||
run: |
|
||||
$shimDir = Join-Path $env:RUNNER_TEMP "headroom-e2e-shims"
|
||||
& pwsh -File e2e/_lib/make_shim.ps1 -Name "${{ inputs.shim-target }}" -Dir $shimDir
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $shimDir
|
||||
"shim-dir=$shimDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
||||
- name: Export shim dir to job output
|
||||
if: ${{ inputs.shim-target != '' }}
|
||||
id: shim
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
echo "shim-dir=${{ steps.shim-windows.outputs.shim-dir }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "shim-dir=${{ steps.shim-posix.outputs.shim-dir }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -0,0 +1,7 @@
|
||||
When performing a pull request review in this repository:
|
||||
|
||||
1. Treat `.github/PULL_REQUEST_TEMPLATE.md` and `CONTRIBUTING.md` as required policy, not optional guidance.
|
||||
2. Flag pull requests that do not include concrete "Real Behavior Proof" with environment, exact commands or steps, observed result, and what was not tested.
|
||||
3. Be strict about contributor verification: missing tests, missing runtime evidence, or placeholder PR text should be called out.
|
||||
4. For user-facing, release, dependency, workflow, or security-sensitive changes, prefer blocking feedback over optional suggestions.
|
||||
5. Focus on correctness, safety, and whether the PR is actually ready for human maintainer review.
|
||||
@@ -0,0 +1,74 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Docker base image digest updates
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
groups:
|
||||
docker-minor-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions version updates
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Python dependency updates (pip)
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
# Only open PRs for security updates to avoid noise
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
pip-minor-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Rust dependency updates (cargo workspace: crates/*)
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
cargo-minor-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# npm dependency updates (TS SDK, plugins, docs site)
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- "/sdk/typescript"
|
||||
- "/plugins/openclaw"
|
||||
- "/plugins/opencode"
|
||||
- "/docs"
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-minor-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "headroom-marketplace",
|
||||
"owner": {
|
||||
"name": "Headroom Contributors"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
||||
"version": "0.31.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "headroom",
|
||||
"source": "./plugins/headroom-agent-hooks",
|
||||
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
||||
"version": "0.31.0",
|
||||
"author": {
|
||||
"name": "Headroom Contributors",
|
||||
"url": "https://github.com/chopratejas/headroom"
|
||||
},
|
||||
"homepage": "https://github.com/chopratejas/headroom",
|
||||
"repository": "https://github.com/chopratejas/headroom",
|
||||
"keywords": [
|
||||
"headroom",
|
||||
"hooks",
|
||||
"claude-code",
|
||||
"copilot-cli"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helpers for PR health maintenance labels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
FAILING_STATES = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "ERROR"}
|
||||
|
||||
|
||||
def _parse_timestamp(value: Any) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
normalized = value.removesuffix("Z") + "+00:00" if value.endswith("Z") else value
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _check_key(check: dict[str, Any]) -> tuple[str, str]:
|
||||
workflow = str(check.get("workflowName") or check.get("workflow") or "")
|
||||
name = str(check.get("name") or check.get("context") or "")
|
||||
return workflow, name
|
||||
|
||||
|
||||
def _check_time(check: dict[str, Any]) -> datetime:
|
||||
return max(
|
||||
_parse_timestamp(check.get("startedAt")),
|
||||
_parse_timestamp(check.get("completedAt")),
|
||||
)
|
||||
|
||||
|
||||
def _state(check: dict[str, Any]) -> str:
|
||||
return str(check.get("conclusion") or check.get("state") or "").upper()
|
||||
|
||||
|
||||
def current_checks(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
latest_by_key: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for check in payload.get("statusCheckRollup") or []:
|
||||
if not isinstance(check, dict):
|
||||
continue
|
||||
key = _check_key(check)
|
||||
if not any(key):
|
||||
continue
|
||||
previous = latest_by_key.get(key)
|
||||
if previous is None or _check_time(check) >= _check_time(previous):
|
||||
latest_by_key[key] = check
|
||||
return list(latest_by_key.values())
|
||||
|
||||
|
||||
def check_state(payload: dict[str, Any]) -> str:
|
||||
for check in current_checks(payload):
|
||||
if _state(check) in FAILING_STATES:
|
||||
return "failing"
|
||||
return "passing"
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--state-json", required=True, help="JSON from gh pr view")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
print(check_state(json.loads(args.state_json)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,555 @@
|
||||
name: CI
|
||||
|
||||
# Intelligent + parallel pipeline (cutover from the old 4-version matrix):
|
||||
# changes — paths-filter; skips heavy work for docs-only changes
|
||||
# build-wheel — compile the Rust ext ONCE (fast `ci` cargo profile), share via artifact
|
||||
# lint — ruff + mypy, once
|
||||
# prefetch-model — download the embedding model ONCE (authenticated), warm shared cache
|
||||
# test — 4 parallel shards (pytest-split), each a fresh runner VM; run offline
|
||||
# test-extras / test-agno / build / commitlint / workflow-validation / *-e2e — preserved
|
||||
#
|
||||
# Notes: CPU-only torch everywhere (no CUDA stack); test shards run HF_HUB_OFFLINE.
|
||||
# Multi-version (3.10/3.11/3.13) coverage on main is a planned follow-up.
|
||||
# Windows wheel (win_amd64) built separately — builds the Rust ext just like the
|
||||
# Linux wheel, then uploads as a separate artifact for downstream consumption.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'wiki/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
# Cancel superseded runs on PRs/branches, but never cancel a main build.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
PY_VERSION: "3.12"
|
||||
# CPU-only torch — runners have no GPU; the default CUDA wheels pull ~2.5 GB.
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
e2e: ${{ steps.filter.outputs.e2e }}
|
||||
workflows: ${{ steps.filter.outputs.workflows }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
code:
|
||||
- 'headroom/**'
|
||||
- 'crates/**'
|
||||
- '**/*.rs'
|
||||
- 'pyproject.toml'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/**'
|
||||
e2e:
|
||||
- 'headroom/**'
|
||||
- 'crates/**'
|
||||
- 'docker/**'
|
||||
- 'Dockerfile'
|
||||
- 'e2e/**'
|
||||
- 'scripts/install*'
|
||||
- 'pyproject.toml'
|
||||
workflows:
|
||||
- '.github/workflows/**'
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-lint-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-lint-
|
||||
- run: python -m pip install --upgrade pip "ruff==0.15.17" "mypy==1.20.2"
|
||||
- name: ruff check
|
||||
run: ruff check .
|
||||
- name: ruff format --check
|
||||
run: ruff format --check .
|
||||
- name: mypy
|
||||
run: mypy headroom --ignore-missing-imports
|
||||
|
||||
build-wheel:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- uses: dtolnay/rust-toolchain@1.96.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- name: Build wheel once (fast CI cargo profile)
|
||||
run: |
|
||||
python -m pip install --upgrade pip maturin
|
||||
maturin build --profile ci --out dist --interpreter "python${PY_VERSION}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: headroom-wheel
|
||||
path: dist/*.whl
|
||||
retention-days: 1
|
||||
|
||||
build-wheel-windows:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- name: Build wheel (fast CI cargo profile)
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip maturin
|
||||
maturin build --profile ci --out dist --interpreter "python${{ env.PY_VERSION }}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: headroom-wheel-windows
|
||||
path: dist/*.whl
|
||||
retention-days: 1
|
||||
|
||||
prefetch-model:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- name: Cache HuggingFace model
|
||||
id: hfcache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-models-allMiniLM-v2
|
||||
- name: Fetch all-MiniLM-L6-v2 once (authenticated, resilient)
|
||||
if: steps.hfcache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||
run: |
|
||||
python -m pip install --upgrade pip huggingface_hub
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if python -c "from huggingface_hub import snapshot_download; snapshot_download('sentence-transformers/all-MiniLM-L6-v2')"; then exit 0; fi
|
||||
echo "::warning::model fetch attempt $i failed; backing off"; sleep $((i * 30))
|
||||
done
|
||||
echo "::error::could not fetch all-MiniLM-L6-v2 from HuggingFace"; exit 1
|
||||
|
||||
test:
|
||||
needs: [changes, build-wheel, prefetch-model]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ env.PY_VERSION }}-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-${{ env.PY_VERSION }}-
|
||||
|
||||
- name: Restore HuggingFace model cache (warmed by prefetch-model)
|
||||
id: restore-hfcache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-models-allMiniLM-v2
|
||||
|
||||
- name: Fallback model download if cache missed
|
||||
if: steps.restore-hfcache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||
TRANSFORMERS_OFFLINE: "0"
|
||||
HF_HUB_OFFLINE: "0"
|
||||
run: |
|
||||
python -m pip install --upgrade pip huggingface_hub
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if python -c "from huggingface_hub import snapshot_download; snapshot_download('sentence-transformers/all-MiniLM-L6-v2')"; then exit 0; fi
|
||||
if [ "$i" -lt 6 ]; then echo "::warning::fallback model fetch attempt $i failed; backing off"; sleep $((i * 30)); fi
|
||||
done
|
||||
echo "::error::could not fetch all-MiniLM-L6-v2 from HuggingFace (fallback)"; exit 1
|
||||
|
||||
- name: Download prebuilt wheel
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: headroom-wheel
|
||||
path: dist
|
||||
|
||||
- name: Install (CPU torch + prebuilt wheel + dev deps, no cargo rebuild)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
|
||||
WHEEL="$(ls dist/*.whl)"
|
||||
pip install "${WHEEL}[dev]" pytest-split
|
||||
# cwd's ./headroom source tree shadows the installed wheel; copy the
|
||||
# compiled extension in so tests import it (no second cargo build).
|
||||
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
|
||||
cp "${SITE}/headroom/"_core*.so headroom/
|
||||
python -c "from headroom._core import DiffCompressor; print('headroom._core OK')"
|
||||
|
||||
- name: Verify offline HuggingFace model cache
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||
run: python scripts/ci/verify_hf_model_cache.py
|
||||
|
||||
# Coverage upload: without this, codecov only receives reports from
|
||||
# the two native-e2e workflows (3 CLI test files total), so head
|
||||
# coverage reads ~6% and codecov/patch fails for ANY diff not
|
||||
# exercised by those files — a false negative on every PR. The main
|
||||
# suite runs here; its coverage must be what codecov sees.
|
||||
- name: Run test shard ${{ matrix.shard }}/4
|
||||
run: |
|
||||
pytest tests scripts/tests \
|
||||
--splits 4 --group ${{ matrix.shard }} \
|
||||
--cov=headroom --cov-branch \
|
||||
--cov-report=xml:coverage-${{ matrix.shard }}.xml \
|
||||
--cov-report= \
|
||||
--tb=short -q
|
||||
|
||||
- name: Upload coverage shard ${{ matrix.shard }} to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage-${{ matrix.shard }}.xml
|
||||
flags: python
|
||||
name: python-shard-${{ matrix.shard }}
|
||||
# Token is sent so uploads authenticate once the repo is activated on
|
||||
# Codecov. Until then Codecov may 404 ("Repository not found"); either
|
||||
# way, coverage upload is reporting-only and must never fail a build
|
||||
# whose tests pass — so this stays non-blocking.
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
test-extras:
|
||||
needs: [changes, build-wheel]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FASTEMBED_CACHE_PATH: ${{ github.workspace }}/.fastembed-cache
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-extras-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-extras-
|
||||
- name: Cache fastembed model
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.fastembed-cache
|
||||
key: ${{ runner.os }}-fastembed-bge-small-v1
|
||||
- name: Download prebuilt wheel
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: headroom-wheel
|
||||
path: dist
|
||||
- name: Install (CPU torch + wheel[dev,relevance])
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
|
||||
WHEEL="$(ls dist/*.whl)"
|
||||
pip install "${WHEEL}[dev,relevance]"
|
||||
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
|
||||
cp "${SITE}/headroom/"_core*.so headroom/
|
||||
python -c "from headroom._core import SmartCrusher; print('headroom._core OK')"
|
||||
- name: Pre-fetch fastembed model (authenticated, resilient)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
if python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')"; then exit 0; fi
|
||||
echo "::warning::fastembed fetch attempt $i failed; backing off"; sleep $((i * 20))
|
||||
done
|
||||
echo "::error::could not fetch fastembed model from HuggingFace"; exit 1
|
||||
- name: Run relevance tests
|
||||
# Offline so fastembed reads the cache the prefetch step just warmed,
|
||||
# without an unauthenticated cache-validation HEAD that could 429.
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
run: pytest tests/test_relevance.py -v
|
||||
|
||||
test-agno:
|
||||
needs: [changes, build-wheel]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- name: Download prebuilt wheel
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: headroom-wheel
|
||||
path: dist
|
||||
- name: Install (CPU torch + wheel[dev,agno])
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
|
||||
WHEEL="$(ls dist/*.whl)"
|
||||
pip install "${WHEEL}[dev,agno]"
|
||||
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
|
||||
cp "${SITE}/headroom/"_core*.so headroom/
|
||||
- name: Run agno tests
|
||||
run: pytest tests/test_integrations/agno/ -v
|
||||
|
||||
test-dashboard-ui:
|
||||
needs: [changes, build-wheel]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
- name: Download prebuilt wheel
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: headroom-wheel
|
||||
path: dist
|
||||
- name: Install (CPU torch + wheel[dev] + playwright)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
|
||||
WHEEL="$(ls dist/*.whl)"
|
||||
pip install "${WHEEL}[dev]" playwright
|
||||
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
|
||||
cp "${SITE}/headroom/"_core*.so headroom/
|
||||
- name: Install chromium
|
||||
run: playwright install --with-deps chromium
|
||||
- name: Run dashboard playwright tests
|
||||
# Stub-based dashboard tests only (routes fully mocked, no network).
|
||||
# tests/test_dashboard/test_live_feed.py needs a live proxy on
|
||||
# localhost:8787 and stays excluded; the main shards keep skipping
|
||||
# these via importorskip since playwright is not installed there.
|
||||
env:
|
||||
HEADROOM_PLAYWRIGHT_ARTIFACT_DIR: ${{ runner.temp }}/playwright-artifacts
|
||||
run: pytest tests/test_dashboard_*_playwright.py -v
|
||||
- name: Upload dashboard screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dashboard-playwright-artifacts
|
||||
path: ${{ runner.temp }}/playwright-artifacts
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: wagoid/commitlint-github-action@v6
|
||||
with:
|
||||
configFile: .commitlintrc.json
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-build-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-build-
|
||||
- uses: dtolnay/rust-toolchain@1.96.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
# Smoke check that the SHIPPED build (release profile) + sdist are wired
|
||||
# right; release.yml's matrix is what actually publishes to PyPI.
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'maturin>=1.5,<2.0' twine
|
||||
- name: Build wheel + sdist
|
||||
run: |
|
||||
maturin sdist --out dist
|
||||
maturin build --release --out dist
|
||||
- name: Check package
|
||||
run: twine check dist/*
|
||||
|
||||
workflow-validation:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.workflows == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Cache actionlint + act
|
||||
id: tools-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bin/actionlint
|
||||
/usr/local/bin/act
|
||||
# Key off the workflow file itself: when someone updates the
|
||||
# download URLs to a newer tool version, the hash changes and
|
||||
# the cache busts automatically.
|
||||
key: ${{ runner.os }}-ci-tools-${{ hashFiles('.github/workflows/ci.yml') }}
|
||||
|
||||
- name: Install actionlint
|
||||
if: steps.tools-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash
|
||||
sudo mv ./actionlint /usr/local/bin/actionlint
|
||||
|
||||
- name: Install act
|
||||
if: steps.tools-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
|
||||
sudo install ./bin/act /usr/local/bin/act
|
||||
- name: Validate workflow files
|
||||
run: bash scripts/validate-workflows.sh
|
||||
|
||||
docker-native-e2e:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Build local Headroom image
|
||||
run: docker build -t headroom-native-e2e:latest .
|
||||
- name: Run Docker-native installer e2e
|
||||
env:
|
||||
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
|
||||
run: bash e2e/docker-native-install.sh
|
||||
- name: Run Docker-native compose smoke test
|
||||
env:
|
||||
HEADROOM_IMAGE: headroom-native-e2e:latest
|
||||
HEADROOM_HOST_HOME: ${{ github.workspace }}
|
||||
HEADROOM_WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p .headroom .claude .codex .gemini
|
||||
trap 'docker compose -f docker/docker-compose.native.yml down -v' EXIT
|
||||
docker compose -f docker/docker-compose.native.yml up -d proxy
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
docker compose -f docker/docker-compose.native.yml logs proxy
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
- name: Run Docker-native wrap e2e
|
||||
run: |
|
||||
docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
|
||||
docker run --rm headroom-wrap-e2e
|
||||
- name: Run Docker-native init e2e
|
||||
run: |
|
||||
docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
|
||||
docker run --rm headroom-init-e2e
|
||||
|
||||
windows-native-wrapper:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest
|
||||
- name: Run native installer wrapper tests
|
||||
run: pytest tests/test_install/test_native_installers.py -q
|
||||
|
||||
macos-native-wrapper:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install bash and test dependencies
|
||||
run: |
|
||||
brew install bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --retries 10 --timeout 60 pytest
|
||||
- name: Run native installer wrapper tests
|
||||
run: |
|
||||
BASH_PREFIX="$(brew --prefix bash)"
|
||||
export PATH="$BASH_PREFIX/bin:$PATH"
|
||||
pytest tests/test_install/test_native_installers.py -q
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Dev Containers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".devcontainer/**"
|
||||
- ".github/workflows/devcontainers.yml"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".devcontainer/**"
|
||||
- ".github/workflows/devcontainers.yml"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: default
|
||||
config: .devcontainer/devcontainer.json
|
||||
- name: memory-stack
|
||||
config: .devcontainer/memory-stack/devcontainer.json
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Both variants exhaust the GitHub runner's disk, so free space up front
|
||||
# on every variant. memory-stack brings up Neo4j + Postgres + Redis +
|
||||
# Qdrant (PR #495: the diagnostic log writer hit "No space left on device"
|
||||
# mid-smoke-test). The default variant's post-create `uv sync` fills the
|
||||
# disk installing the ML wheel set — it failed copying numpy into the venv
|
||||
# with "No space left on device" (os error 28). Previously this ran only
|
||||
# on memory-stack, which left the default variant with no cushion.
|
||||
#
|
||||
# Reclaim ~14 GB by stripping preinstalled tools none of the devcontainer
|
||||
# paths use (Android SDK, .NET, Haskell).
|
||||
- name: Free runner disk
|
||||
uses: jlumbroso/free-disk-space@v1.3.1
|
||||
with:
|
||||
tool-cache: true
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: false
|
||||
swap-storage: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Install Dev Container CLI
|
||||
run: npm install -g @devcontainers/cli@0.85.0
|
||||
|
||||
- name: Start ${{ matrix.name }}
|
||||
run: devcontainer up --workspace-folder . --config ${{ matrix.config }} --remove-existing-container
|
||||
|
||||
- name: Smoke test ${{ matrix.name }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.name }}" == "memory-stack" ]]; then
|
||||
devcontainer exec --workspace-folder . --config ${{ matrix.config }} bash -lc 'git rev-parse --show-toplevel >/dev/null && uv --version && node --version && gh --version >/dev/null && uv run python -c "import socket; socket.create_connection((\"qdrant\", 6333), 5).close(); socket.create_connection((\"neo4j\", 7687), 5).close(); from mem0 import Memory; from qdrant_client import QdrantClient; import neo4j; import headroom; print(\"memory-stack smoke test passed\")"'
|
||||
else
|
||||
devcontainer exec --workspace-folder . --config ${{ matrix.config }} bash -lc 'git rev-parse --show-toplevel >/dev/null && uv --version && node --version && gh --version >/dev/null && uv run python -c "import headroom; print(\"default smoke test passed\")"'
|
||||
fi
|
||||
|
||||
validate-worktree:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# The worktree devcontainer runs the same `uv sync --extra dev` build as
|
||||
# the default validate job, which now pulls transformers 5.x. Copying that
|
||||
# into the venv volume exhausts the GitHub runner's disk ("No space left on
|
||||
# device", see PR #495). Reclaim ~14 GB by stripping preinstalled tools the
|
||||
# build never touches — mirrors the memory-stack job's existing remedy.
|
||||
- name: Free runner disk
|
||||
uses: jlumbroso/free-disk-space@v1.3.1
|
||||
with:
|
||||
tool-cache: true
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: false
|
||||
swap-storage: false
|
||||
|
||||
- name: Create linked worktree
|
||||
run: git worktree add "$RUNNER_TEMP/headroom-worktree" HEAD
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Install Dev Container CLI
|
||||
run: npm install -g @devcontainers/cli@0.85.0
|
||||
|
||||
- name: Start linked worktree devcontainer
|
||||
run: devcontainer up --workspace-folder "$RUNNER_TEMP/headroom-worktree" --config "$RUNNER_TEMP/headroom-worktree/.devcontainer/devcontainer.json" --remove-existing-container
|
||||
|
||||
- name: Smoke test linked worktree
|
||||
run: devcontainer exec --workspace-folder "$RUNNER_TEMP/headroom-worktree" --config "$RUNNER_TEMP/headroom-worktree/.devcontainer/devcontainer.json" bash -lc 'git rev-parse --show-toplevel && uv run python -c "import headroom; print(\"worktree smoke test passed\")"'
|
||||
@@ -0,0 +1,428 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to stamp into the image contents and exact image tag"
|
||||
required: false
|
||||
type: string
|
||||
enable_ref_tags:
|
||||
description: "Whether to emit branch/PR ref tags"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to stamp into the image contents and exact image tag"
|
||||
required: false
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write # For cosign keyless signing via Sigstore OIDC
|
||||
|
||||
jobs:
|
||||
# ─── Per-arch fan-out ──────────────────────────────────────────────────────
|
||||
# Build each variant on its native architecture in parallel:
|
||||
# linux/amd64 → ubuntu-24.04 (native x86_64)
|
||||
# linux/arm64 → ubuntu-24.04-arm (native aarch64, GA Jan 2025)
|
||||
#
|
||||
# Pre-#377 we ran a single matrix job per variant on `ubuntu-latest` and
|
||||
# let bake's `platforms = ["linux/amd64", "linux/arm64"]` do multi-arch
|
||||
# via QEMU emulation — ~1h per variant. Native arm64 runners drop QEMU
|
||||
# entirely and cut each variant to ~10 min on each arch in parallel.
|
||||
#
|
||||
# Each per-arch build pushes by digest only (no tags). The
|
||||
# `docker-manifest` job below combines the per-arch digests into the
|
||||
# final multi-arch tagged manifest, which is what users pull by tag.
|
||||
docker-build:
|
||||
runs-on: ${{ matrix.arch.runs_on }}
|
||||
timeout-minutes: 75
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- { name: "", bake_target: runtime }
|
||||
- { name: nonroot, bake_target: runtime-nonroot }
|
||||
- { name: code, bake_target: runtime-code }
|
||||
- { name: code-nonroot, bake_target: runtime-code-nonroot }
|
||||
- { name: slim, bake_target: runtime-slim }
|
||||
- { name: slim-nonroot, bake_target: runtime-slim-nonroot }
|
||||
- { name: code-slim, bake_target: runtime-code-slim }
|
||||
- { name: code-slim-nonroot, bake_target: runtime-code-slim-nonroot }
|
||||
arch:
|
||||
- { name: amd64, runs_on: ubuntu-24.04, platform: linux/amd64 }
|
||||
- { name: arm64, runs_on: ubuntu-24.04-arm, platform: linux/arm64 }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Normalize image name
|
||||
id: image-name
|
||||
run: |
|
||||
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Determine image version
|
||||
id: version
|
||||
env:
|
||||
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
version="${MANUAL_VERSION#v}"
|
||||
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
|
||||
version="${RELEASE_TAG#v}"
|
||||
fi
|
||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.version.outputs.version != ''
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Sync versioned files for image build
|
||||
if: steps.version.outputs.version != ''
|
||||
run: |
|
||||
python scripts/version-sync.py --version ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Labels (not tags) for the per-arch image. Tags belong on the
|
||||
# multi-arch index manifest and are applied in docker-manifest.
|
||||
- name: Extract image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||
|
||||
- name: Build and push by digest (single platform)
|
||||
id: bake
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
files: |
|
||||
./docker-bake.hcl
|
||||
cwd://${{ steps.meta.outputs.bake-file-labels }}
|
||||
targets: ${{ matrix.variant.bake_target }}
|
||||
push: true
|
||||
# `*.platform` overrides the [amd64,arm64] default in
|
||||
# docker-bake.hcl. `push-by-digest=true,name-canonical=true`
|
||||
# tells buildx to push the per-platform manifest with no tags
|
||||
# — only the digest is recorded — so multiple per-arch builds
|
||||
# can coexist in the registry until the manifest job stitches
|
||||
# them. `name=<registry>/<image>` is REQUIRED here: with no
|
||||
# `bake-file-tags` in scope (tags belong on the manifest, not
|
||||
# per-arch), bake has no way to know the push target without
|
||||
# the explicit `name=`. Removing it surfaces as the
|
||||
# misleading "ERROR: tag is needed when pushing to registry"
|
||||
# — see PR #378 (regression from #376). GHA cache is scoped
|
||||
# per (variant, arch) so the two arches don't fight over the
|
||||
# same cache key.
|
||||
set: |
|
||||
*.platform=${{ matrix.arch.platform }}
|
||||
*.output=type=image,name=${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
*.cache-from=type=gha,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
|
||||
*.cache-to=type=gha,mode=max,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
|
||||
|
||||
- name: Export digest
|
||||
id: digest
|
||||
env:
|
||||
BAKE_METADATA: ${{ steps.bake.outputs.metadata }}
|
||||
run: |
|
||||
# Bake's metadata is one entry per target; for a single-target
|
||||
# single-platform build it has exactly one digest. Pipe the
|
||||
# JSON through a file (same ARG_MAX rationale as before) and
|
||||
# extract that digest.
|
||||
cat > "${RUNNER_TEMP}/bake_meta.json" <<'__HEADROOM_BAKE_META_EOF__'
|
||||
${{ steps.bake.outputs.metadata }}
|
||||
__HEADROOM_BAKE_META_EOF__
|
||||
digest="$(jq -r 'to_entries[0].value."containerimage.digest" // empty' \
|
||||
"${RUNNER_TEMP}/bake_meta.json")"
|
||||
if [ -z "$digest" ]; then
|
||||
echo "ERROR: no digest in bake metadata" >&2
|
||||
cat "${RUNNER_TEMP}/bake_meta.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
|
||||
# Stage a marker file named after the bare hex digest. The
|
||||
# manifest job downloads all per-arch markers for a variant
|
||||
# and reconstructs `IMAGE@sha256:<digest>` references from
|
||||
# the filenames.
|
||||
mkdir -p "${RUNNER_TEMP}/digests"
|
||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||
|
||||
# Smoke-test the built image before recording its digest. If the
|
||||
# Python ABI is wrong (e.g. builder Python 3.11 vs distroless
|
||||
# Python 3.13) pydantic_core._pydantic_core fails to dlopen and
|
||||
# the import raises ModuleNotFoundError. Catching it here prevents
|
||||
# a broken digest from reaching the manifest merge job and being
|
||||
# tagged and published. Both python-slim and distroless variants
|
||||
# expose python3 in PATH and honour the image's PYTHONPATH env.
|
||||
- name: Smoke-test image (pydantic_core + headroom._core)
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||
DIGEST: ${{ steps.digest.outputs.digest }}
|
||||
PLATFORM: ${{ matrix.arch.platform }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
--platform "$PLATFORM" \
|
||||
--entrypoint python3 \
|
||||
"${IMAGE}@${DIGEST}" \
|
||||
-c "
|
||||
import pydantic_core
|
||||
from headroom._core import DiffCompressor, SmartCrusher
|
||||
print('smoke-test OK: pydantic_core', pydantic_core.__version__,
|
||||
'| DiffCompressor', DiffCompressor.__name__,
|
||||
'| SmartCrusher', SmartCrusher.__name__)
|
||||
"
|
||||
|
||||
- name: Upload digest marker
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Variant + arch in the artifact name so the manifest job can
|
||||
# download with `pattern: digests-<variant>-*` to gather all
|
||||
# arches for one variant. `root` substitutes the empty-string
|
||||
# variant since GHA artifact names can't end in a hyphen.
|
||||
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ─── Per-variant manifest merge ────────────────────────────────────────────
|
||||
# One job per variant, after both arch builds for that variant complete.
|
||||
# `docker buildx imagetools create` stitches the two per-arch digests
|
||||
# into a single multi-arch index manifest, applies the metadata-action
|
||||
# tags, and that manifest is what users pull by `:tag`.
|
||||
docker-manifest:
|
||||
needs: docker-build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- { name: "", bake_target: runtime }
|
||||
- { name: nonroot, bake_target: runtime-nonroot }
|
||||
- { name: code, bake_target: runtime-code }
|
||||
- { name: code-nonroot, bake_target: runtime-code-nonroot }
|
||||
- { name: slim, bake_target: runtime-slim }
|
||||
- { name: slim-nonroot, bake_target: runtime-slim-nonroot }
|
||||
- { name: code-slim, bake_target: runtime-code-slim }
|
||||
- { name: code-slim-nonroot, bake_target: runtime-code-slim-nonroot }
|
||||
|
||||
steps:
|
||||
# No `actions/checkout` here: the manifest job only calls
|
||||
# `docker buildx imagetools` against the registry and runs
|
||||
# cosign — neither needs the repo on disk. Skipping checkout
|
||||
# saves a few seconds across 8 parallel manifest jobs.
|
||||
- name: Normalize image name
|
||||
id: image-name
|
||||
run: |
|
||||
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Determine image version
|
||||
id: version
|
||||
env:
|
||||
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
version="${MANUAL_VERSION#v}"
|
||||
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
|
||||
version="${RELEASE_TAG#v}"
|
||||
fi
|
||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute short SHA
|
||||
id: short-sha
|
||||
run: printf 'sha=%s\n' "${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download per-arch digests for this variant
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: digests-${{ matrix.variant.name || 'root' }}-*
|
||||
path: ${{ runner.temp }}/digests
|
||||
merge-multiple: true
|
||||
|
||||
# Same tag rules as the pre-fan-out workflow — preserve every
|
||||
# tag flavor (semver, ref, sha-prefixed, version-suffixed,
|
||||
# bare variant) so existing pull URLs keep working.
|
||||
- name: Extract metadata (variant)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||
tags: |
|
||||
type=ref,event=branch,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=ref,event=pr,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=raw,value=dev,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name == 'push' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ steps.version.outputs.version != '' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}-${{ steps.short-sha.outputs.sha }},enable=${{ steps.version.outputs.version != '' && matrix.variant.name == '' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}-${{ matrix.variant.name }}-${{ steps.short-sha.outputs.sha }},enable=${{ steps.version.outputs.version != '' && matrix.variant.name != '' }}
|
||||
type=semver,pattern={{version}},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=semver,pattern={{major}},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
|
||||
type=sha,format=short,prefix=${{ matrix.variant.name != '' && format('{0}-', matrix.variant.name) || 'sha-' }}
|
||||
type=raw,value=${{ matrix.variant.name }},enable=${{ matrix.variant.name != '' }}
|
||||
|
||||
- name: Create multi-arch manifest
|
||||
id: manifest
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||
DIGEST_DIR: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
# Reconstruct full image references from the digest marker
|
||||
# filenames (each file is named after the bare hex digest
|
||||
# of one per-arch manifest).
|
||||
if ! ls "${DIGEST_DIR}"/* >/dev/null 2>&1; then
|
||||
echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
digest_refs=()
|
||||
for f in "${DIGEST_DIR}"/*; do
|
||||
digest="$(basename "$f")"
|
||||
digest_refs+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
# Build `--tag` args from the metadata-action JSON output.
|
||||
# Empty tags array is valid (PR builds without ref-tags
|
||||
# enabled emit nothing); skip manifest creation in that case.
|
||||
tag_args=()
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] && tag_args+=("--tag" "$tag")
|
||||
done < <(jq -r '.tags[]?' <<< '${{ steps.meta.outputs.json }}')
|
||||
|
||||
if [ "${#tag_args[@]}" -eq 0 ]; then
|
||||
echo "No tags to apply for variant '${{ matrix.variant.name || 'root' }}'; skipping manifest."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
"${tag_args[@]}" \
|
||||
"${digest_refs[@]}"
|
||||
|
||||
# Resolve the index manifest digest of the freshly pushed
|
||||
# multi-arch manifest so cosign can sign it directly. We
|
||||
# ask the registry via `imagetools inspect` and read the
|
||||
# `.manifest.digest` field — that's the registry's own
|
||||
# record of the index digest (no client-side hashing).
|
||||
first_tag="$(jq -r '.tags[0]' <<< '${{ steps.meta.outputs.json }}')"
|
||||
index_digest="$(docker buildx imagetools inspect "${first_tag}" \
|
||||
--format '{{ json . }}' | jq -r '.manifest.digest')"
|
||||
if [ -z "$index_digest" ] || [ "$index_digest" = "null" ]; then
|
||||
echo "ERROR: could not resolve index digest for ${first_tag}" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'index_digest=%s\n' "$index_digest" >> "$GITHUB_OUTPUT"
|
||||
printf 'first_tag=%s\n' "$first_tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install cosign
|
||||
if: steps.manifest.outputs.index_digest != ''
|
||||
uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Sign multi-arch index manifest with cosign
|
||||
if: steps.manifest.outputs.index_digest != ''
|
||||
env:
|
||||
# Same routing as before: keep signature artifacts in a
|
||||
# sibling GHCR package so the main image's version listing
|
||||
# stays clean. Verifiers must export the same
|
||||
# COSIGN_REPOSITORY when running 'cosign verify'. See the
|
||||
# pre-#377 workflow for the GHCR/OCI-1.1 referrers context.
|
||||
COSIGN_REPOSITORY: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}-signatures
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||
INDEX_DIGEST: ${{ steps.manifest.outputs.index_digest }}
|
||||
run: |
|
||||
target="${IMAGE}@${INDEX_DIGEST}"
|
||||
echo "Signing ${target} (signatures -> ${COSIGN_REPOSITORY})"
|
||||
for attempt in 1 2 3; do
|
||||
if cosign sign --yes "${target}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: cosign signing failed after ${attempt} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep_for=$((attempt * 10))
|
||||
echo "cosign signing failed on attempt ${attempt}; retrying in ${sleep_for}s" >&2
|
||||
sleep "$sleep_for"
|
||||
done
|
||||
|
||||
promote-latest:
|
||||
# Re-push the :latest tag pointing at the root variant *after* every
|
||||
# variant manifest job has finished, so GHCR's package version
|
||||
# listing (sorted by created_at) shows the root image with :latest
|
||||
# at the top instead of whichever variant happened to finish last.
|
||||
needs: docker-manifest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Normalize image name
|
||||
id: image-name
|
||||
run: |
|
||||
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Determine image version
|
||||
id: version
|
||||
env:
|
||||
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
version="${MANUAL_VERSION#v}"
|
||||
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
|
||||
version="${RELEASE_TAG#v}"
|
||||
fi
|
||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Re-tag root image as :latest
|
||||
if: steps.version.outputs.version != ''
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# Add a unique annotation so the resulting image index manifest gets
|
||||
# a new digest, which makes GHCR record a fresh package version with
|
||||
# current timestamp (otherwise the existing root manifest is reused
|
||||
# and stays where it was in the version listing).
|
||||
promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker buildx imagetools create \
|
||||
--annotation "index:io.headroom.promoted-at=${promoted_at}" \
|
||||
--tag "${IMAGE}:latest" \
|
||||
"${IMAGE}:${VERSION}"
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'wiki/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-docs-${{ hashFiles('mkdocs.yml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-docs-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install mkdocs-material
|
||||
|
||||
- name: Build docs
|
||||
run: mkdocs build
|
||||
|
||||
deploy:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-docs-${{ hashFiles('mkdocs.yml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-docs-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install mkdocs-material
|
||||
|
||||
- name: Build and deploy
|
||||
run: mkdocs gh-deploy --force
|
||||
@@ -0,0 +1,152 @@
|
||||
name: Evaluation Suite
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday 6am UTC
|
||||
workflow_dispatch: # Manual trigger
|
||||
pull_request:
|
||||
paths:
|
||||
- 'headroom/transforms/**'
|
||||
- 'headroom/evals/**'
|
||||
- 'headroom/compress.py'
|
||||
|
||||
jobs:
|
||||
# Fast smoke test on PRs touching compression code (~$0.05, ~2 min)
|
||||
smoke-test:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-eval-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-eval-
|
||||
|
||||
# `pip install -e .` invokes maturin (declared in pyproject.toml's
|
||||
# build-system) which calls cargo to compile the Rust extension.
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1.96.0
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- name: Install dependencies (builds Rust extension via maturin)
|
||||
run: |
|
||||
pip install -e ".[all]"
|
||||
python -c "from headroom._core import SmartCrusher; print('headroom._core OK:', SmartCrusher)"
|
||||
|
||||
- name: Run CCR round-trip (zero cost)
|
||||
run: |
|
||||
python -c "
|
||||
from headroom.evals.runners.compression_only import CompressionOnlyRunner
|
||||
runner = CompressionOnlyRunner()
|
||||
cases = runner.generate_ccr_test_cases(n=50)
|
||||
result = runner.evaluate_ccr_lossless(cases)
|
||||
print(f'CCR Round-trip: {result.passed_cases}/{result.total_cases} passed')
|
||||
assert result.passed, f'CCR failures: {result.errors}'
|
||||
"
|
||||
|
||||
- name: Run tool schema compaction integrity eval (zero cost)
|
||||
run: |
|
||||
python -c "
|
||||
from headroom.evals.runners.compression_only import CompressionOnlyRunner
|
||||
runner = CompressionOnlyRunner()
|
||||
result = runner.evaluate_tool_schema_compaction()
|
||||
print(f'Tool schema compaction: {result.passed_cases}/{result.total_cases} passed, {result.total_tokens_saved} annotation tokens stripped')
|
||||
assert result.passed, f'Schema compaction failures: {result.errors}'
|
||||
"
|
||||
|
||||
# OPENAI_API_KEY is intentionally not set in the public OSS repo
|
||||
# (the secret list is empty). The CCR round-trip step above is the
|
||||
# mandatory gate; this step only runs when an operator has wired
|
||||
# OPENAI_API_KEY as a repo secret (e.g. on a downstream fork). When
|
||||
# missing, emit a loud GitHub `::warning::` annotation so the skip
|
||||
# is visible in the run summary — never a silent pass.
|
||||
- name: Run built-in tool output eval (skipped when OPENAI_API_KEY unset)
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${OPENAI_API_KEY}" ]; then
|
||||
echo "::warning title=Smoke eval skipped::OPENAI_API_KEY is not configured for this repo; only the CCR round-trip gate ran. Wire the secret to enable the live OpenAI eval."
|
||||
exit 0
|
||||
fi
|
||||
python -m headroom.evals quick -n 8 --provider openai --model gpt-4o-mini
|
||||
|
||||
# Full Tier 1 suite, weekly or manual (~$3-5, ~30-45 min)
|
||||
weekly-suite:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-eval-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-eval-
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1.96.0
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- name: Install dependencies (builds Rust extension via maturin)
|
||||
run: |
|
||||
pip install -e ".[all]"
|
||||
python -c "from headroom._core import SmartCrusher; print('headroom._core OK')"
|
||||
- name: Run Tier 1 evaluation suite
|
||||
run: |
|
||||
if [ -z "${OPENAI_API_KEY}" ]; then
|
||||
echo "::warning title=Weekly eval skipped::OPENAI_API_KEY is not configured for this repo; skipping the live Tier 1 suite."
|
||||
mkdir -p eval_results
|
||||
printf '%s\n\n%s\n' \
|
||||
'# Weekly Evaluation Skipped' \
|
||||
'OPENAI_API_KEY is not configured for this repository, so the live Tier 1 evaluation suite was skipped.' \
|
||||
> eval_results/skipped.md
|
||||
exit 0
|
||||
fi
|
||||
python -m headroom.evals suite --tier 1 --ci -o eval_results/
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Recall-based fidelity report on the production routing path. Zero cost
|
||||
# (synthetic structured cases -> Rust compressors; no model, no API, no
|
||||
# secrets). Non-blocking: surfaces recall trends weekly without gating.
|
||||
# The blocking per-PR fidelity gate lives in
|
||||
# tests/test_compression_fidelity_regression.py (runs in the [dev] shard).
|
||||
- name: Information-retention recall report (zero cost, non-blocking)
|
||||
run: |
|
||||
python -c "
|
||||
from headroom.evals.runners.compression_only import CompressionOnlyRunner
|
||||
runner = CompressionOnlyRunner()
|
||||
cases = runner.generate_info_retention_cases(n=50)
|
||||
result = runner.evaluate_information_retention(cases)
|
||||
print(f'Information retention: {result.passed_cases}/{result.total_cases} cases >=0.9 recall, avg compression {result.avg_compression_ratio:.1%}')
|
||||
if not result.passed:
|
||||
print(f'::warning title=Fidelity recall::{result.failed_cases} case(s) fell below 0.9 recall: {result.errors[:3]}')
|
||||
"
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eval-results-${{ github.run_number }}
|
||||
path: eval_results/
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Init E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'headroom/**'
|
||||
- 'crates/**'
|
||||
- 'docker/**'
|
||||
- 'Dockerfile'
|
||||
- 'e2e/**'
|
||||
- 'scripts/install*'
|
||||
- 'pyproject.toml'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'uv.lock'
|
||||
- '.claude-plugin'
|
||||
- '.github/plugin/**'
|
||||
- 'plugins/headroom-agent-hooks/**'
|
||||
- '.github/workflows/init-e2e.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: init-e2e-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
docker-init-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Build init e2e image
|
||||
run: docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
|
||||
|
||||
- name: Run init e2e container
|
||||
run: docker run --rm headroom-init-e2e
|
||||
@@ -0,0 +1,140 @@
|
||||
name: Init Native E2E
|
||||
|
||||
# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand
|
||||
# ``headroom init -g <target>`` flows. Each matrix cell drops a noop shim for
|
||||
# the target agent onto PATH and asserts ``headroom init -g <target>``
|
||||
# succeeds, writes the expected settings file, and (for claude/codex) places
|
||||
# hooks in the right place.
|
||||
#
|
||||
# Deliberately scoped to pull_request + push-to-main + workflow_dispatch to
|
||||
# avoid bloating CI minutes on every push to every feature branch. The Docker
|
||||
# init-e2e.yml still runs on every PR and provides the deeper functional
|
||||
# coverage; this workflow exists to catch platform-specific bugs (Windows
|
||||
# path separators, macos keychain prompts, PowerShell-vs-bash hook matchers)
|
||||
# that the single-platform Docker suite can miss.
|
||||
#
|
||||
# Extending to other commands (``headroom install``, ``headroom wrap``) is
|
||||
# expected to be a near-copy of this file. The shared composite action at
|
||||
# ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so
|
||||
# each per-command workflow only supplies its matrix and assertion steps.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "headroom/cli/init.py"
|
||||
- "headroom/install/**"
|
||||
- "e2e/_lib/**"
|
||||
- "e2e/init/**"
|
||||
- ".github/actions/headroom-e2e-setup/**"
|
||||
- ".github/workflows/init-native-e2e.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
init-native:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Windows is excluded today: upstream `esaxx-rs` (transitively from
|
||||
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
|
||||
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
|
||||
# Rust extension cannot build for `win_amd64` until the upstream
|
||||
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
|
||||
# builds cleanly there. Tracked in the project plan; not a blocker
|
||||
# for headroom-ai installs on Linux + macOS.
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
target: [claude, codex, copilot, openclaw]
|
||||
exclude:
|
||||
# openclaw delegates to ``headroom wrap openclaw`` which needs a
|
||||
# running OpenClaw CLI; it can't be shimmed cheaply, so it's
|
||||
# covered by the bundled Docker e2e instead.
|
||||
- target: openclaw
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup (shim=${{ matrix.target }})
|
||||
uses: ./.github/actions/headroom-e2e-setup
|
||||
with:
|
||||
install-mode: deps-only-proxy
|
||||
python-version: "3.11"
|
||||
shim-target: ${{ matrix.target }}
|
||||
|
||||
- name: Verify shim is on PATH (POSIX)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
which "${{ matrix.target }}"
|
||||
|
||||
- name: Verify shim is on PATH (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# On Windows the shim is ``<target>.cmd``; Get-Command resolves via
|
||||
# PATHEXT (same as Python's ``shutil.which`` used by headroom init).
|
||||
# Git Bash's ``which`` cannot find ``.cmd`` shims, so we use pwsh.
|
||||
$cmd = Get-Command "${{ matrix.target }}" -ErrorAction Stop
|
||||
Write-Output $cmd.Source
|
||||
|
||||
- name: Run headroom init -g ${{ matrix.target }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
headroom init -g "${{ matrix.target }}"
|
||||
|
||||
- name: Assert settings file (POSIX)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.target }}" in
|
||||
claude)
|
||||
test -f "$HOME/.claude/settings.json"
|
||||
grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json"
|
||||
;;
|
||||
codex)
|
||||
test -f "$HOME/.codex/config.toml"
|
||||
test -f "$HOME/.codex/hooks.json"
|
||||
grep -q "headroom" "$HOME/.codex/config.toml"
|
||||
;;
|
||||
copilot)
|
||||
test -f "$HOME/.copilot/config.json"
|
||||
grep -q "SessionStart" "$HOME/.copilot/config.json"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Assert settings file (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$home_ = $env:USERPROFILE
|
||||
switch ("${{ matrix.target }}") {
|
||||
"claude" {
|
||||
$p = Join-Path $home_ ".claude\settings.json"
|
||||
if (-not (Test-Path $p)) { throw "Missing $p" }
|
||||
if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) {
|
||||
throw "settings.json missing ANTHROPIC_BASE_URL"
|
||||
}
|
||||
}
|
||||
"codex" {
|
||||
$c = Join-Path $home_ ".codex\config.toml"
|
||||
$h = Join-Path $home_ ".codex\hooks.json"
|
||||
if (-not (Test-Path $c)) { throw "Missing $c" }
|
||||
if (-not (Test-Path $h)) { throw "Missing $h" }
|
||||
if (-not ((Get-Content $c -Raw) -match "headroom")) {
|
||||
throw "config.toml missing headroom provider"
|
||||
}
|
||||
}
|
||||
"copilot" {
|
||||
$p = Join-Path $home_ ".copilot\config.json"
|
||||
if (-not (Test-Path $p)) { throw "Missing $p" }
|
||||
if (-not ((Get-Content $p -Raw) -match "SessionStart")) {
|
||||
throw "copilot config missing SessionStart hooks"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Install Native E2E
|
||||
|
||||
# Cross-platform smoke tests for safe ``headroom install`` paths. The goal here
|
||||
# is portable CLI coverage that runs on real runners without mutating OS service
|
||||
# managers or requiring Docker. Deeper lifecycle behavior remains covered by the
|
||||
# existing native installer wrapper tests and install unit tests.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "headroom/cli/install.py"
|
||||
- "headroom/install/**"
|
||||
- "tests/test_cli/test_install_cli.py"
|
||||
- "tests/test_install/test_paths.py"
|
||||
- ".github/actions/headroom-e2e-setup/**"
|
||||
- ".github/workflows/install-native-e2e.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
install-native:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Windows is excluded today: upstream `esaxx-rs` (transitively from
|
||||
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
|
||||
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
|
||||
# Rust extension cannot build for `win_amd64` until the upstream
|
||||
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
|
||||
# builds cleanly there. Match init-native-e2e.yml so this workflow
|
||||
# doesn't fail during setup before the install smoke tests run.
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/headroom-e2e-setup
|
||||
with:
|
||||
install-mode: deps-only-proxy
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install pytest
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --retries 10 --timeout 60 pytest pytest-cov
|
||||
|
||||
- name: Run install native smoke tests
|
||||
shell: bash
|
||||
run: |
|
||||
pytest tests/test_cli/test_install_cli.py tests/test_install/test_paths.py --cov=headroom --cov-report=xml:coverage-install-native.xml --cov-report=term-missing -q
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage-install-native.xml
|
||||
flags: install-native
|
||||
name: install-native-${{ matrix.os }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Merge Conflicts
|
||||
|
||||
# Reject unresolved Git merge-conflict markers committed into tracked files.
|
||||
#
|
||||
# This lives in its own workflow ON PURPOSE: ci.yml sets
|
||||
# `on.pull_request.paths-ignore: ['**/*.md', ...]`, so a Markdown/CHANGELOG-only
|
||||
# PR skips that workflow entirely. The conflict markers this guard exists to
|
||||
# catch landed in CHANGELOG.md, so the check must run with no `paths-ignore`.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
merge-conflicts:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Reject unresolved merge-conflict markers
|
||||
run: |
|
||||
if git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)' -- .; then
|
||||
echo "::error::Unresolved Git merge-conflict markers found in tracked files (see matches above)."
|
||||
exit 1
|
||||
fi
|
||||
echo "No merge-conflict markers found."
|
||||
@@ -0,0 +1,164 @@
|
||||
name: Network Diff Capture
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: network-diff-capture-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
PY_VERSION: "3.12"
|
||||
|
||||
jobs:
|
||||
offline:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
|
||||
- name: Install offline test tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install \
|
||||
'tiktoken>=0.5.0' \
|
||||
'pydantic>=2.0.0' \
|
||||
'litellm==1.82.3' \
|
||||
'click>=8.1.0' \
|
||||
'rich>=13.0.0' \
|
||||
'opentelemetry-api>=1.24.0' \
|
||||
'ast-grep-cli>=0.30.0' \
|
||||
'fastapi>=0.100.0' \
|
||||
'uvicorn>=0.23.0' \
|
||||
'httpx[http2]>=0.24.0' \
|
||||
'openai>=2.14.0' \
|
||||
'mcp>=1.0.0' \
|
||||
'magika>=0.6.0' \
|
||||
'zstandard>=0.20.0' \
|
||||
'websockets>=13.0' \
|
||||
'onnxruntime>=1.16.0' \
|
||||
'transformers>=4.30.0' \
|
||||
'watchdog>=4.0.0' \
|
||||
'sqlite-vec>=0.1.6' \
|
||||
pytest ruff mypy
|
||||
|
||||
- name: Lint capture code
|
||||
run: ruff check headroom/capture headroom/cli/capture.py tests/test_network_diff_capture.py
|
||||
|
||||
- name: Format check capture code
|
||||
run: ruff format --check headroom/capture headroom/cli/capture.py tests/test_network_diff_capture.py
|
||||
|
||||
- name: Type-check capture code
|
||||
run: mypy headroom/capture/network_diff.py headroom/cli/capture.py
|
||||
|
||||
- name: Run capture tests
|
||||
run: python -m pytest tests/test_network_diff_capture.py
|
||||
|
||||
- name: Validate compose model
|
||||
env:
|
||||
ANTHROPIC_API_KEY: dummy
|
||||
run: docker compose -f docker/differential-network-capture/docker-compose.yml --profile run config
|
||||
|
||||
- name: Build Claude Code runner image
|
||||
env:
|
||||
ANTHROPIC_API_KEY: dummy
|
||||
run: docker compose -f docker/differential-network-capture/docker-compose.yml --profile run build claude-direct
|
||||
|
||||
- name: Smoke Claude Code runner image
|
||||
run: docker run --rm -e CLAUDE_COMMAND="claude --version" headroom-network-diff-claude-direct:latest
|
||||
|
||||
live-anthropic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
needs: offline
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_PROMPT: "Summarize this repository in one sentence. Keep the answer under 30 words."
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PY_VERSION }}
|
||||
|
||||
- name: Install report dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install \
|
||||
'tiktoken>=0.5.0' \
|
||||
'pydantic>=2.0.0' \
|
||||
'litellm==1.82.3' \
|
||||
'click>=8.1.0' \
|
||||
'rich>=13.0.0' \
|
||||
'opentelemetry-api>=1.24.0' \
|
||||
'ast-grep-cli>=0.30.0' \
|
||||
'fastapi>=0.100.0' \
|
||||
'uvicorn>=0.23.0' \
|
||||
'httpx[http2]>=0.24.0' \
|
||||
'openai>=2.14.0' \
|
||||
'mcp>=1.0.0' \
|
||||
'magika>=0.6.0' \
|
||||
'zstandard>=0.20.0' \
|
||||
'websockets>=13.0' \
|
||||
'onnxruntime>=1.16.0' \
|
||||
'transformers>=4.30.0' \
|
||||
'watchdog>=4.0.0' \
|
||||
'sqlite-vec>=0.1.6'
|
||||
|
||||
- name: Run live Claude Code differential capture
|
||||
if: env.ANTHROPIC_API_KEY != ''
|
||||
working-directory: docker/differential-network-capture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p captures
|
||||
docker compose up -d --build mitm-direct mitm-headroom-upstream headroom-proxy mitm-headroom-client
|
||||
trap 'docker compose --profile run down -v' EXIT
|
||||
|
||||
for i in $(seq 1 90); do
|
||||
if docker compose exec -T headroom-proxy curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 90 ]; then
|
||||
docker compose logs headroom-proxy
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker compose --profile run run --rm claude-direct
|
||||
docker compose --profile run run --rm claude-headroom
|
||||
|
||||
- name: Report skipped live capture
|
||||
if: env.ANTHROPIC_API_KEY == ''
|
||||
run: |
|
||||
echo "::warning title=Live network diff skipped::ANTHROPIC_API_KEY is not configured for this repository; offline harness checks ran, but live Claude Code capture was skipped."
|
||||
mkdir -p docker/differential-network-capture/captures
|
||||
cat > docker/differential-network-capture/captures/skipped.md <<'EOF'
|
||||
# Live Network Diff Capture Skipped
|
||||
|
||||
`ANTHROPIC_API_KEY` is not configured for this repository.
|
||||
EOF
|
||||
|
||||
- name: Generate network diff report
|
||||
if: env.ANTHROPIC_API_KEY != ''
|
||||
run: |
|
||||
python -m headroom.cli capture network-diff \
|
||||
--direct docker/differential-network-capture/captures/direct.jsonl \
|
||||
--headroom docker/differential-network-capture/captures/headroom-client.jsonl \
|
||||
--output docker/differential-network-capture/captures/report.md \
|
||||
--json-output docker/differential-network-capture/captures/report.json
|
||||
|
||||
- name: Upload capture artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: network-diff-capture-${{ github.run_number }}
|
||||
path: docker/differential-network-capture/captures/
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,41 @@
|
||||
name: OpenCode Plugin
|
||||
|
||||
# The OpenCode plugin (plugins/opencode) is the routing shim that carries
|
||||
# `headroom wrap opencode` traffic through the proxy, yet nothing in CI ever
|
||||
# compiled it — so TypeScript / @types/node major bumps and source changes had
|
||||
# zero build evidence. This gate runs the plugin's own typecheck + build + test
|
||||
# whenever it (or this workflow) changes.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/opencode/**"
|
||||
- ".github/workflows/opencode-plugin.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/opencode/**"
|
||||
- ".github/workflows/opencode-plugin.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: typecheck + build + test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugins/opencode
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: plugins/opencode/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
@@ -0,0 +1,228 @@
|
||||
name: PR Governance
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
schedule:
|
||||
# Keep labels fresh even when base branches move or checks finish later.
|
||||
- cron: '23 14 * * 1-5'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
concurrency:
|
||||
group: pr-health-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
template:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Fetch current PR body
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > .pr-body.md
|
||||
|
||||
- name: Validate PR template
|
||||
id: validate
|
||||
run: python3 scripts/pr-governance.py --event "$GITHUB_EVENT_PATH" --body-file .pr-body.md --report .pr-governance-report.json
|
||||
|
||||
- name: Append governance summary
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
report = json.loads(Path(".pr-governance-report.json").read_text(encoding="utf-8"))
|
||||
summary = report["summary_markdown"].strip()
|
||||
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{summary}\n")
|
||||
PY
|
||||
|
||||
- name: Ensure governance labels exist
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh label create "status: needs author action" \
|
||||
--repo "$REPO" \
|
||||
--color "d93f0b" \
|
||||
--description "Pull request body or readiness checklist still needs author updates" \
|
||||
--force
|
||||
gh label create "status: ready for review" \
|
||||
--repo "$REPO" \
|
||||
--color "0e8a16" \
|
||||
--description "Pull request body is complete and the author marked it ready for human review" \
|
||||
--force
|
||||
|
||||
- name: Sync governance comment and labels
|
||||
if: steps.validate.outputs.is_bot_pr != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REPORT_PATH: .pr-governance-report.json
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issue_number = context.payload.pull_request.number;
|
||||
const marker = report.comment_marker;
|
||||
const body = `${marker}\n${report.comment_markdown}`.trim();
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.type === 'Bot' && typeof comment.body === 'string' && comment.body.includes(marker),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
if (report.labels_to_add.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: report.labels_to_add,
|
||||
});
|
||||
}
|
||||
|
||||
for (const label of report.labels_to_remove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Report incomplete PR body
|
||||
if: steps.validate.outputs.valid != 'true'
|
||||
run: |
|
||||
echo "PR template validation found missing fields. The governance comment and labels identify the required author updates."
|
||||
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Ensure maintenance labels exist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh label create "status: needs rebase" \
|
||||
--repo "$REPO" \
|
||||
--color "fbca04" \
|
||||
--description "Pull request branch is behind the base branch" \
|
||||
--force
|
||||
gh label create "status: has conflicts" \
|
||||
--repo "$REPO" \
|
||||
--color "d73a4a" \
|
||||
--description "Pull request has merge conflicts with the base branch" \
|
||||
--force
|
||||
gh label create "status: ci failing" \
|
||||
--repo "$REPO" \
|
||||
--color "d73a4a" \
|
||||
--description "Required or reported CI checks are failing" \
|
||||
--force
|
||||
gh label create "status: needs author action" \
|
||||
--repo "$REPO" \
|
||||
--color "d93f0b" \
|
||||
--description "Pull request body or readiness checklist still needs author updates" \
|
||||
--force
|
||||
gh label create "status: ready for review" \
|
||||
--repo "$REPO" \
|
||||
--color "0e8a16" \
|
||||
--description "Pull request body is complete and the author marked it ready for human review" \
|
||||
--force
|
||||
|
||||
- name: Label open pull requests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if jq -e '.pull_request.number' "$GITHUB_EVENT_PATH" >/dev/null; then
|
||||
pr_numbers="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")"
|
||||
else
|
||||
pr_numbers="$(gh pr list --repo "$REPO" --state open --limit 100 --json number --jq '.[].number')"
|
||||
fi
|
||||
|
||||
for pr in $pr_numbers; do
|
||||
data="$(gh pr view "$pr" --repo "$REPO" \
|
||||
--json isDraft,labels,mergeStateStatus,reviewDecision,statusCheckRollup)"
|
||||
|
||||
merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$data")"
|
||||
check_state="$(python3 .github/scripts/pr-health-labels.py --state-json "$data")"
|
||||
is_draft="$(jq -r '.isDraft' <<<"$data")"
|
||||
review_decision="$(jq -r '.reviewDecision // ""' <<<"$data")"
|
||||
|
||||
if [[ "$merge_state" == "BEHIND" ]]; then
|
||||
gh pr edit "$pr" --repo "$REPO" --add-label "status: needs rebase"
|
||||
elif [[ "$merge_state" != "UNKNOWN" ]]; then
|
||||
gh pr edit "$pr" --repo "$REPO" --remove-label "status: needs rebase" || true
|
||||
fi
|
||||
|
||||
if [[ "$merge_state" == "DIRTY" ]]; then
|
||||
gh pr edit "$pr" --repo "$REPO" --add-label "status: has conflicts"
|
||||
elif [[ "$merge_state" != "UNKNOWN" ]]; then
|
||||
gh pr edit "$pr" --repo "$REPO" --remove-label "status: has conflicts" || true
|
||||
fi
|
||||
|
||||
if [[ "$check_state" == "failing" ]]; then
|
||||
gh pr edit "$pr" --repo "$REPO" --add-label "status: ci failing"
|
||||
else
|
||||
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ci failing" || true
|
||||
fi
|
||||
|
||||
if [[ "$merge_state" == "BEHIND" || "$merge_state" == "DIRTY" || "$check_state" == "failing" || "$is_draft" == "true" || "$review_decision" == "CHANGES_REQUESTED" ]]; then
|
||||
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ready for review" || true
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Publish to PyPI (manual fallback)
|
||||
|
||||
# DEPRECATED: This workflow is superseded by release.yml's matrix-based
|
||||
# build-and-publish flow. It exists as a manual fallback in case release.yml
|
||||
# is broken and a hotfix needs to be pushed without going through the
|
||||
# normal tag-driven pipeline. Single-platform; produces only the linux
|
||||
# x86_64 wheel + sdist. For full cross-platform release, use release.yml.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write # For trusted publishing
|
||||
contents: write # For uploading release assets
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1.96.0
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip 'maturin>=1.5,<2.0' cyclonedx-bom
|
||||
|
||||
- name: Build wheel + sdist
|
||||
run: |
|
||||
maturin sdist --out dist
|
||||
maturin build --release --out dist
|
||||
|
||||
- name: Generate SBOM (CycloneDX)
|
||||
run: |
|
||||
pip install -e ".[proxy]"
|
||||
cyclonedx-py environment \
|
||||
--output-format json \
|
||||
--outfile dist/headroom-sbom.cdx.json
|
||||
|
||||
- name: Upload SBOM to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/headroom-sbom.cdx.json
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.13.0
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Release Please
|
||||
|
||||
# What this does
|
||||
# ----------------
|
||||
# release-please watches `main` for conventional-commit traffic and
|
||||
# maintains a single "Release vX.Y.Z" PR that aggregates everything
|
||||
# released since the last tag. Merging that PR is what triggers an
|
||||
# actual PyPI / npm / GitHub-Release publish (via release.yml, which
|
||||
# fires on the published-release event the bot emits at merge time).
|
||||
#
|
||||
# This replaces the prior "every push to main is a release" pattern
|
||||
# that burned PyPI's per-project storage quota by uploading a fresh
|
||||
# wheel matrix (~200 MB) for each merged `fix:` / `feat:` PR.
|
||||
#
|
||||
# Day-to-day:
|
||||
# - Merge a `fix:` PR into main -> bot updates the release PR
|
||||
# - Merge a `feat:` PR into main -> bot bumps minor in release PR
|
||||
# - Merge `ci:` / `docs:` / `chore:` -> no PR change (hidden)
|
||||
# - Ready to ship -> merge the release PR
|
||||
# (bot tags + emits release event;
|
||||
# release.yml does the actual builds + publishes)
|
||||
#
|
||||
# Config lives in `.release-please-config.json`; current versions
|
||||
# tracked in `.release-please-manifest.json`.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
# Serialize bot runs on main so two pushes don't race the
|
||||
# release-PR update. We never cancel mid-flight — losing a manifest
|
||||
# write would mean the next push computes the wrong base version.
|
||||
group: release-please-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v5
|
||||
with:
|
||||
# PAT (not GITHUB_TOKEN): a release/tag created by GITHUB_TOKEN does
|
||||
# NOT emit events that trigger other workflows, so release.yml
|
||||
# (PyPI/npm) and docker.yml — which fire on `release: published` —
|
||||
# never ran, and releases had to be cut by hand. A PAT is treated as a
|
||||
# real user, so the release it creates DOES trigger those publishes; it
|
||||
# also lets the bot tag past branch/tag protection. Falls back to
|
||||
# GITHUB_TOKEN when the secret is unset (the release PR still opens; it
|
||||
# just won't trigger the downstream publishes).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: .release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
name: rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, rust-rewrite ]
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'tests/parity/**'
|
||||
- 'Makefile'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'tests/parity/**'
|
||||
- 'Makefile'
|
||||
- '.github/workflows/rust.yml'
|
||||
schedule:
|
||||
# Nightly parity run at 07:17 UTC (weekdays only). Phase 0 allows failure.
|
||||
- cron: '17 7 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: rust-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Default permissions: read-only. Individual jobs override only what they need.
|
||||
# Mitigates CodeQL/CWE-275 (missing-workflow-permissions): the GITHUB_TOKEN
|
||||
# defaults to whatever the repo policy is, which can be read-write. Pinning
|
||||
# this here means even if the repo default changes, this workflow stays safe.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (ubuntu)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install stable toolchain
|
||||
# Pin action code to @stable (latest fixes), toolchain version
|
||||
# via input. The @1.95.0 ref shipped action code that errors on
|
||||
# ubuntu-latest with `detected conflict: 'bin/cargo-clippy'`
|
||||
# because the pre-installed runner Rust collides with the
|
||||
# clippy-preview component install.
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: rustfmt, clippy
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: cargo fmt --check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
- name: cargo test
|
||||
run: cargo test --workspace
|
||||
|
||||
simulator-e2e:
|
||||
name: simulator e2e (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install stable toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: cargo test simulator-backed proxy e2e
|
||||
run: cargo test -p headroom-proxy --test e2e_simulators
|
||||
|
||||
wheels:
|
||||
name: wheels (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
maturin-target: x86_64
|
||||
- os: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
maturin-target: aarch64-apple-darwin
|
||||
- os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
maturin-target: x86_64-apple-darwin
|
||||
# Intel macOS uses `ort-load-dynamic` (no prebuilt ORT from ort-sys);
|
||||
# Apple Silicon bundles ORT via `ort-download-binaries-rustls-tls`.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: "Build wheel (single-wheel architecture builds headroom-ai)"
|
||||
uses: PyO3/maturin-action@v1
|
||||
# Maturin reads `[tool.maturin]` from the root `pyproject.toml`
|
||||
# which points at `crates/headroom-py/Cargo.toml` for the cdylib.
|
||||
# Output is `headroom_ai-<ver>-<py>-<py>-<platform>.whl` containing
|
||||
# both Python source and the compiled `headroom/_core.so`.
|
||||
with:
|
||||
command: build
|
||||
args: --release --out dist
|
||||
target: ${{ matrix.maturin-target }}
|
||||
- name: Upload wheel artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheels-${{ matrix.target }}
|
||||
path: dist/*.whl
|
||||
|
||||
audit:
|
||||
name: audit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-audit + cargo-deny
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-audit,cargo-deny
|
||||
- name: cargo audit (soft-fail)
|
||||
continue-on-error: true
|
||||
run: cargo audit
|
||||
- name: cargo deny check licenses
|
||||
continue-on-error: true
|
||||
run: cargo deny check licenses
|
||||
|
||||
parity-nightly:
|
||||
name: parity (nightly, allowed to fail during Phase 0)
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install maturin
|
||||
pip install -e .
|
||||
- name: Run parity harness
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
make test-parity
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Security
|
||||
|
||||
# Security gate: dependency vulnerability scanning (SCA), static analysis
|
||||
# (CodeQL/SAST), and secret scanning. Runs on every PR to main, on push to
|
||||
# main, weekly (to catch newly-disclosed CVEs without a code change), and on
|
||||
# demand. Each job is an independent required check.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Mondays 06:00 UTC — surface CVEs disclosed since the last commit.
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
# ---- SCA: dependency vulnerability scan -------------------------------
|
||||
# Audits the PRODUCTION dependency set ([all]) exported from uv.lock. The
|
||||
# `benchmark` extra is intentionally excluded from [all] (it pulls lm-eval's
|
||||
# sqlitedict/nltk, which carry unpatchable upstream High CVEs and are never
|
||||
# installed in production), so this gate fails only on actionable findings.
|
||||
dependency-audit:
|
||||
name: Dependency audit (pip-audit)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Export production dependency set from uv.lock
|
||||
run: |
|
||||
uv export --frozen --no-dev --no-emit-project --no-hashes \
|
||||
--extra all --format requirements-txt > requirements-prod.txt
|
||||
echo "Production dependencies audited:"
|
||||
wc -l requirements-prod.txt
|
||||
|
||||
- name: Audit dependencies (pip-audit)
|
||||
uses: pypa/gh-action-pip-audit@v1.1.0
|
||||
with:
|
||||
inputs: requirements-prod.txt
|
||||
|
||||
# ---- SAST: CodeQL static analysis ------------------------------------
|
||||
codeql:
|
||||
name: CodeQL (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-extended
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# ---- Secret scanning -------------------------------------------------
|
||||
# Uses the gitleaks BINARY (MIT-licensed, no key) instead of
|
||||
# gitleaks-action, which requires a paid GITLEAKS_LICENSE for organization
|
||||
# repos. On PRs we scan only the PR's commits so pre-existing history can't
|
||||
# block a PR; on push/schedule we scan the working tree. Config + allowlist
|
||||
# live in .gitleaks.toml at the repo root (auto-loaded).
|
||||
secret-scan:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
version=8.18.4
|
||||
curl -sSfL \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_linux_x64.tar.gz" \
|
||||
-o /tmp/gitleaks.tar.gz
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
sudo install /tmp/gitleaks /usr/local/bin/gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Scan for secrets
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
echo "Scanning PR commits ${BASE_SHA}..HEAD"
|
||||
gitleaks detect --source . --log-opts="${BASE_SHA}..HEAD" --redact --no-banner
|
||||
else
|
||||
echo "Scanning working tree"
|
||||
gitleaks detect --source . --no-git --redact --no-banner
|
||||
fi
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Stale Triage
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Daily weekday pass during US morning hours.
|
||||
- cron: '17 15 * * 1-5'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: stale-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Ensure stale label exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh label create "status: stale" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--color "ededed" \
|
||||
--description "No recent activity; may be closed if it stays inactive" \
|
||||
--force
|
||||
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
operations-per-run: 200
|
||||
remove-stale-when-updated: true
|
||||
exempt-all-milestones: true
|
||||
exempt-issue-labels: pinned,security,good first issue,help wanted,needs reproduction
|
||||
exempt-pr-labels: pinned,security,dependencies,release,do not merge
|
||||
|
||||
stale-issue-label: "status: stale"
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 14
|
||||
stale-issue-message: >
|
||||
This issue has had no recent activity and is being marked stale.
|
||||
Please comment with new context if it is still relevant.
|
||||
close-issue-message: >
|
||||
Closing this issue due to continued inactivity. It can be reopened
|
||||
if there is new information or a clear next step.
|
||||
|
||||
stale-pr-label: "status: stale"
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-close: 14
|
||||
stale-pr-message: >
|
||||
This pull request has had no recent activity and is being marked
|
||||
stale. Please rebase, resolve conflicts, or comment if it is still
|
||||
actively being worked.
|
||||
close-pr-message: >
|
||||
Closing this pull request due to continued inactivity. It can be
|
||||
reopened when it is ready for review again.
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Wrap E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'headroom/**'
|
||||
- 'crates/**'
|
||||
- 'docker/**'
|
||||
- 'Dockerfile'
|
||||
- 'e2e/**'
|
||||
- 'scripts/install*'
|
||||
- 'pyproject.toml'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'uv.lock'
|
||||
- 'sdk/typescript/**'
|
||||
- 'plugins/openclaw/**'
|
||||
- '.github/workflows/wrap-e2e.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: wrap-e2e-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
docker-wrap-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Build wrap e2e image
|
||||
run: docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
|
||||
|
||||
- name: Run wrap e2e container
|
||||
run: docker run --rm headroom-wrap-e2e
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Wrap Native E2E
|
||||
|
||||
# Cross-platform smoke tests for the hidden ``headroom wrap ... --prepare-only``
|
||||
# flows. These reuse the existing pytest bridge cases so we exercise the real
|
||||
# CLI on linux / macos without depending on agent binaries or long-lived proxy
|
||||
# processes. Windows will be added once the upstream CRT conflict is resolved
|
||||
# (see matrix comment below).
|
||||
#
|
||||
# This complements the Docker-native wrap e2e by catching host-specific issues
|
||||
# such as home-directory layout and filesystem quirks in prepare-only config
|
||||
# injection.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "headroom/cli/**"
|
||||
- "headroom/providers/**"
|
||||
- "headroom/rtk/**"
|
||||
- "tests/test_cli/test_wrap_bridge.py"
|
||||
- ".github/actions/headroom-e2e-setup/**"
|
||||
- ".github/workflows/wrap-native-e2e.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wrap-native:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Windows is excluded today: upstream `esaxx-rs` (transitively from
|
||||
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
|
||||
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
|
||||
# Rust extension cannot build for `win_amd64` until the upstream
|
||||
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
|
||||
# builds cleanly there. Match init-native-e2e.yml so this workflow
|
||||
# doesn't fail during setup before the wrap smoke tests run.
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/headroom-e2e-setup
|
||||
with:
|
||||
install-mode: deps-only-proxy
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install pytest
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --retries 10 --timeout 60 pytest pytest-cov
|
||||
|
||||
- name: Run wrap native bridge tests
|
||||
shell: bash
|
||||
run: |
|
||||
pytest tests/test_cli/test_wrap_bridge.py --cov=headroom --cov-report=xml:coverage-wrap-native.xml --cov-report=term-missing -q
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage-wrap-native.xml
|
||||
flags: wrap-native
|
||||
name: wrap-native-${{ matrix.os }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
# fastembed model cache (auto-downloaded ONNX weights, ~30 MB+).
|
||||
# Should NEVER be committed — bloats the repo significantly.
|
||||
.fastembed_cache/
|
||||
**/.fastembed_cache/
|
||||
|
||||
# Local Kompress ONNX export artifacts (scripts/export_kompress_v2_onnx.py).
|
||||
# Hundreds of MB each — published to HuggingFace, never committed.
|
||||
/onnx/
|
||||
|
||||
# Private scripts (contain credentials). Allowlist checked-in helpers below.
|
||||
scripts/
|
||||
!scripts/
|
||||
scripts/*
|
||||
!scripts/install.sh
|
||||
!scripts/install.ps1
|
||||
!scripts/version-sync.py
|
||||
!scripts/sync-plugin-versions.py
|
||||
!scripts/changelog-gen.py
|
||||
!scripts/verify-versions.py
|
||||
!scripts/pr-governance.py
|
||||
!scripts/tests/
|
||||
!scripts/README.md
|
||||
!scripts/repro_codex_replay.py
|
||||
!scripts/eval_output_shaper.py
|
||||
!scripts/fixtures/
|
||||
!scripts/fixtures/*.json
|
||||
!scripts/record_fixtures.py
|
||||
!scripts/build_rust_extension.sh
|
||||
!scripts/install-git-hooks.sh
|
||||
!scripts/smoke_issue_327.py
|
||||
!scripts/refresh_model_limits.sh
|
||||
!scripts/audit_wheel_glibc_symbols.py
|
||||
!scripts/replay_codex_ws_load.py
|
||||
!scripts/export_kompress_v2_onnx.py
|
||||
|
||||
# Rust / Cargo build artifacts
|
||||
/target/
|
||||
**/target/
|
||||
Cargo.lock.bak
|
||||
|
||||
# Swift SDK (separate repo)
|
||||
swift/
|
||||
|
||||
# Local planning docs (never commit)
|
||||
ENTERPRISE_HARDENING.md
|
||||
|
||||
# Audit/scan outputs (contain security findings — never commit)
|
||||
bandit_result.txt
|
||||
pip_audit_result.txt
|
||||
ruff_result.txt
|
||||
reqs.txt
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.*
|
||||
!.env.act.example
|
||||
!.env.example
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.python-version
|
||||
|
||||
# Node.js dependencies (never commit vendored deps)
|
||||
node_modules/
|
||||
|
||||
# Secrets and API keys - NEVER commit these
|
||||
*.pem
|
||||
*.key
|
||||
secrets.json
|
||||
credentials.json
|
||||
.secrets
|
||||
api_keys.txt
|
||||
.anthropic
|
||||
.openai
|
||||
|
||||
# IDE and editors
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.spyproject
|
||||
.spyderproject
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
*.ipynb
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
|
||||
# Thumbnails
|
||||
Icon?
|
||||
._*
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Linux
|
||||
*~
|
||||
|
||||
# Local configuration
|
||||
local_settings.py
|
||||
*.local.py
|
||||
*.local.json
|
||||
*.local.yaml
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
log/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# Benchmark results (keep framework, not results)
|
||||
.benchmarks/
|
||||
benchmark_results.json
|
||||
benchmark_results/
|
||||
|
||||
# DeepEval cache
|
||||
.deepeval/
|
||||
|
||||
# Headroom specific
|
||||
.headroom/
|
||||
headroom.db
|
||||
headroom_*.db
|
||||
*.jsonl
|
||||
!tests/fixtures/*.jsonl
|
||||
docker/differential-network-capture/captures/
|
||||
|
||||
# Documentation build
|
||||
docs/_build/
|
||||
site/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Ruff
|
||||
.ruff_cache/
|
||||
|
||||
# pyright
|
||||
pyrightconfig.json
|
||||
|
||||
# Editor backup files
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
|
||||
# Local development configuration
|
||||
CLAUDE.md
|
||||
|
||||
# Vitals provenance data
|
||||
.vitals/
|
||||
|
||||
# Separate private repos — never commit here
|
||||
headroom-managed/
|
||||
|
||||
# Local act testing (never commit test tokens)
|
||||
/.env.act
|
||||
.actrc.local
|
||||
|
||||
# Release metadata artifact
|
||||
.releaseetadata
|
||||
|
||||
# uv lockfile: regenerated locally; not committed
|
||||
uv.lock
|
||||
|
||||
# Rust extension `.so` symlinks placed by `scripts/build_rust_extension.sh`
|
||||
# into the `headroom/` package dir for local development. The real binary
|
||||
# lives in `crates/headroom-py/python/headroom/`; this is the dev overlay
|
||||
# that lets `import headroom._core` resolve when the source `headroom/`
|
||||
# package shadows the maturin overlay on sys.path.
|
||||
/headroom/_core.*.so
|
||||
/headroom/_core.so
|
||||
.tokensave
|
||||
@@ -0,0 +1,27 @@
|
||||
# gitleaks configuration — extends the tuned default ruleset and allowlists
|
||||
# paths that contain hashes/identifiers (not real secrets) to avoid false
|
||||
# positives. Used by the Security workflow's secret-scan job.
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Non-secret artifacts: SBOMs, lockfiles, vendored hashes, test/benchmark fixtures, and verified example values."
|
||||
paths = [
|
||||
'''sbom/.*''',
|
||||
'''.*\.lock$''',
|
||||
'''.*package-lock\.json$''',
|
||||
'''pnpm-lock\.yaml$''',
|
||||
# Test / benchmark / parity trees use synthetic JWTs and API keys by design.
|
||||
'''(^|/)tests/''',
|
||||
'''(^|/)benchmarks/''',
|
||||
'''crates/.*/(tests|benches)/''',
|
||||
]
|
||||
# Verified non-secret strings that appear in production source. Kept narrow
|
||||
# (exact tokens) so a genuine secret in these files would still be caught.
|
||||
regexes = [
|
||||
'''eyJhbGciOiJIUzI1NiIs''', # example JWT header prefix in a docstring (headroom/config.py)
|
||||
'''sk-ant-dummy''', # documented placeholder key in the CLI banner (headroom/cli/proxy.py)
|
||||
'''ANTHROPIC_API_KEY=''', # env-var NAME shown in CLI help text (headroom/cli/proxy.py)
|
||||
'''Iv1\.b507a08c87ecfe98''', # GitHub Copilot PUBLIC OAuth client_id (not a secret)
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: sync-plugin-versions
|
||||
name: Sync plugin versions
|
||||
entry: python3 scripts/sync-plugin-versions.py
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: commitlint
|
||||
name: Commitlint
|
||||
entry: bash -lc 'npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional -- commitlint --edit "$1" --config .commitlintrc.json' --
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-merge-conflict
|
||||
# Catch markers even outside an in-progress merge (e.g. committing a
|
||||
# botched conflict resolution from a rebase). CI re-checks this
|
||||
# unconditionally, so installing hooks is not required for enforcement.
|
||||
args: [--assume-in-merge]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
exclude: ^experiments/
|
||||
- id: ruff-format
|
||||
exclude: ^experiments/
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.14.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
args: [--ignore-missing-imports]
|
||||
exclude: ^experiments/
|
||||
pass_filenames: false
|
||||
entry: mypy headroom
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"release-type": "python",
|
||||
"include-v-in-tag": true,
|
||||
"include-component-in-tag": false,
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": false,
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"separate-pull-requests": false,
|
||||
"pull-request-title-pattern": "chore: release ${version}",
|
||||
"packages": {
|
||||
".": {
|
||||
"package-name": "headroom-ai",
|
||||
"release-type": "python",
|
||||
"extra-files": [
|
||||
{
|
||||
"type": "json",
|
||||
"path": "sdk/typescript/package.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "plugins/openclaw/package.json",
|
||||
"jsonpath": "$.version"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"changelog-sections": [
|
||||
{ "type": "feat", "section": "Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "perf", "section": "Performance Improvements" },
|
||||
{ "type": "deps", "section": "Dependencies" },
|
||||
{ "type": "revert", "section": "Reverts" },
|
||||
{ "type": "refactor", "section": "Code Refactoring" },
|
||||
{ "type": "ci", "section": "Continuous Integration", "hidden": true },
|
||||
{ "type": "build", "section": "Build System", "hidden": true },
|
||||
{ "type": "chore", "section": "Miscellaneous Chores", "hidden": true },
|
||||
{ "type": "docs", "section": "Documentation", "hidden": true },
|
||||
{ "type": "style", "section": "Styles", "hidden": true },
|
||||
{ "type": "test", "section": "Tests", "hidden": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.31.0"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "feature-opencode-wrap"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
+1174
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**conduct@headroomlabs.ai**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Contributing to Headroom
|
||||
|
||||
Thanks for contributing! Please skim this before opening a PR : the policies exist because we've been burned skipping them, not because we love paperwork.
|
||||
|
||||
By participating, you agree to our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Where does my contribution go?
|
||||
|
||||
| Type | What to do |
|
||||
| --- | --- |
|
||||
| 🐛 Bug or small fix | **Open a PR** (with repro + test) |
|
||||
| ✨ New feature / architectural change | **Open an issue or ask in Discord first.** |
|
||||
| 🧹 Refactor-only | **Don't.** Only if a maintainer asked, as part of a concrete fix. |
|
||||
| 🧪 Test/CI-only PR chasing a known `main` failure | **Don't.** We're tracking it. |
|
||||
| 📦 New dep or version bump | **PR with written justification.** |
|
||||
| ❓ Question | Ask in **Discord `#help`** |
|
||||
|
||||
**Open PR cap: 10 per author.** Get existing ones merged before opening more.
|
||||
|
||||
## Guiding principles
|
||||
|
||||
- **Verification is the author's job, not the reviewer's.**
|
||||
- **Supply chain is a real threat.** Dependency changes get human review, every time.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
Every bug-fix PR must include:
|
||||
|
||||
1. **A reproduction** — minimal code, failing test, or steps.
|
||||
2. **A test that fails before your fix and passes after** (unit, integration, or e2e).
|
||||
|
||||
If you genuinely can't write a test, say so explicitly and explain how you verified.
|
||||
|
||||
## "Real behavior proof" — required on every external PR
|
||||
|
||||
We can't merge what we can't verify. Include a **`Real behavior proof`** section in the PR body covering:
|
||||
|
||||
- **Setup you tested on** (OS, Python, config, provider/model)
|
||||
- **Exact command or steps you ran after the patch**
|
||||
- **After-fix evidence** + **observed result**
|
||||
- **What you did *not* test**
|
||||
|
||||
✅ Counts: screenshots, recordings, terminal output, copied live output, linked artifacts, redacted runtime logs.
|
||||
❌ Does **not** count alone: unit tests, mocks, snapshots, lint, typechecks, green CI. Have them too — but they prove the test passes, not that the feature works.
|
||||
|
||||
**PRs missing this may be autoclosed.**
|
||||
|
||||
## New features
|
||||
|
||||
Before writing code:
|
||||
|
||||
1. **Open a feature-request issue** (or raise in Discord).
|
||||
2. **Get a 👍 from a core maintainer** before implementing.
|
||||
3. **Include a short spec** covering:
|
||||
- **API surface** (public functions, config, CLI flags)
|
||||
- **Changes to existing behavior**
|
||||
- **User stories** — Given / When / Then, golden path + one edge case
|
||||
- **Failure modes**
|
||||
- **Recovery / resilience**
|
||||
- **Security considerations**
|
||||
|
||||
Short and concrete beats long.
|
||||
|
||||
## Dependencies & supply chain
|
||||
|
||||
A human maintainer reviews every dep change. PRs that add or bump a package must justify:
|
||||
|
||||
- **Why this package** (vs. doing it ourselves / using existing deps)
|
||||
- **Who maintains it** (activity, release cadence, security history)
|
||||
- **Install surface** (transitive deps, native code, install/runtime network)
|
||||
- **Why this version** — permitted reasons: **bug fix**, **security patch**, **required new functionality**. Cosmetic bumps will be closed.
|
||||
|
||||
## PR workflow
|
||||
|
||||
1. Fork, branch from `main`.
|
||||
2. Install **Node 18+** and run `uv sync --extra dev` then `make install-git-hooks` — installs repo pre-commit checks on every commit, commitlint on every commit message, and ci-precheck on every push.
|
||||
3. One logical change per PR.
|
||||
4. Add tests.
|
||||
5. `uv run pytest` · `uv run ruff check .` · `uv run ruff format .`
|
||||
6. Update `CHANGELOG.md` for user-facing changes.
|
||||
7. Open the PR with a clear description + `Real behavior proof` + any spec/justification required, and keep the PR in draft until the `Review Readiness` boxes are complete.
|
||||
|
||||
**Title format** (conventional commits): `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
|
||||
|
||||
**Commit message format** is enforced locally by the repo's `commit-msg` hook and again in CI.
|
||||
|
||||
**Review:** CI green, one maintainer review, coverage held/improved.
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/chopratejas/headroom.git
|
||||
cd headroom
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
node --version # Node 18+ required for commitlint hooks
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev,relevance,proxy]"
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Headroom uses a `pyproject.toml`/`maturin` build backend. Older `pip`
|
||||
versions may fail editable installs by looking for `setup.py`; upgrade `pip`
|
||||
first or use `uv sync --extra dev`.
|
||||
|
||||
### Dev Containers
|
||||
|
||||
Two configs ship for VS Code / Codespaces:
|
||||
|
||||
- **`.devcontainer/devcontainer.json`** — Python 3.12, `uv`, Node.js, `gh`.
|
||||
- **`.devcontainer/memory-stack/devcontainer.json`** — adds Qdrant + Neo4j sidecars (use `qdrant:6333`, `neo4j://neo4j:7687`).
|
||||
|
||||
Inside, use: `uv run ruff check .`, `uv run pytest`, etc.
|
||||
|
||||
## Optional automated review
|
||||
|
||||
This repository includes `.github/copilot-instructions.md` so maintainers can opt into GitHub Copilot code review without adding workflow billing noise to every PR.
|
||||
|
||||
Enable or disable automatic Copilot review in **Settings → Rules → Rulesets → Automatically request Copilot code review**. Keep it off unless maintainers explicitly want the extra review traffic.
|
||||
|
||||
## Coding standards
|
||||
|
||||
- [Ruff](https://github.com/astral-sh/ruff) for lint + format, line length 100, PEP 8.
|
||||
- Type hints on public functions; Google-style docstrings.
|
||||
- Cover new behavior + edge cases; aim >80% coverage on new code.
|
||||
- Python 3.10+. Optional features go behind extras.
|
||||
|
||||
## Architecture principles
|
||||
|
||||
**Safety first:** never drop user/assistant content, never break tool call/response pairing, malformed content passes through unchanged, prefer false negatives.
|
||||
|
||||
**Performance:** transforms <50ms at P99, lazy-load optional deps, profile before optimizing.
|
||||
|
||||
Contributors are credited in `CHANGELOG`, the GitHub contributors page, and release notes. Thanks again. 💚
|
||||
Generated
+5391
File diff suppressed because it is too large
Load Diff
+130
@@ -0,0 +1,130 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/headroom-core",
|
||||
"crates/headroom-proxy",
|
||||
"crates/headroom-simulators",
|
||||
"crates/headroom-py",
|
||||
"crates/headroom-parity",
|
||||
]
|
||||
# headroom-py is a Python extension module — it must be built via maturin, not
|
||||
# plain cargo (the "extension-module" feature tells pyo3 not to link libpython,
|
||||
# which is required for `import` to work). `cargo build --workspace` without
|
||||
# explicit members skips it; `cargo test --workspace` still runs its tests
|
||||
# because pyo3 can dynamically link here for the cdylib used by tests.
|
||||
default-members = [
|
||||
"crates/headroom-core",
|
||||
"crates/headroom-proxy",
|
||||
"crates/headroom-simulators",
|
||||
"crates/headroom-parity",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/chopratejas/headroom"
|
||||
authors = ["Headroom Maintainers"]
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# `preserve_order` makes `serde_json::Value::Object` use IndexMap so JSON
|
||||
# parse order is preserved through Value→string→Value round-trips. The
|
||||
# smart_crusher port relies on this to match Python's `str(dict)` output,
|
||||
# which preserves insertion order; otherwise BTreeMap's sorted-key default
|
||||
# would diverge from Python on every multi-key object.
|
||||
#
|
||||
# `arbitrary_precision` keeps the literal numeric token from the source
|
||||
# JSON intact: `Value::Number` becomes a wrapper around the original
|
||||
# digit string, so `1.0` does NOT collapse to `1`, and `12345678901234567`
|
||||
# does NOT lose precision through f64. Required by Realignment invariant
|
||||
# I1 (byte-faithful passthrough on unmutated bytes; see REALIGNMENT/02-
|
||||
# architecture.md §2.2) and PR-A4 (see REALIGNMENT/03-phase-A-lockdown.md).
|
||||
#
|
||||
# `raw_value` exposes `serde_json::value::RawValue`, the unparsed JSON
|
||||
# fragment type. Phase B PR-B2 uses this to forward unmodified
|
||||
# `messages[*]` entries as exact byte copies — the parser captures the
|
||||
# original byte slice, so byte-for-byte round-trips work even with
|
||||
# whitespace, key order, or escape preferences the producer chose.
|
||||
# Enabled here in Phase A so PR-B2 can land as a pure consumer change.
|
||||
serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision", "raw_value"] }
|
||||
bytes = "1"
|
||||
thiserror = "2"
|
||||
# `log` compat: when no tracing subscriber is active (the case inside the
|
||||
# headroom-py cdylib), events are re-emitted as `log` records so pyo3-log
|
||||
# can forward them to Python's logging. No effect on binaries that install
|
||||
# a real subscriber.
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
axum = "0.7"
|
||||
tower = "0.5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
pyo3 = { version = "0.29", features = ["abi3-py310"] }
|
||||
# Forwards Rust `log` records (incl. tracing events via the `log` compat
|
||||
# feature above) into Python's `logging` inside the _core extension module.
|
||||
pyo3-log = "0.13"
|
||||
# Phase D PR-D1: AWS SigV4 signing for native Bedrock InvokeModel route.
|
||||
# `aws-sigv4` provides the canonical-request + signing-key implementation;
|
||||
# `aws-config` resolves credentials from the standard provider chain
|
||||
# (env vars, profiles, IMDS, ECS task role, etc); `aws-credential-types`
|
||||
# exposes `Credentials` so the signer accepts whatever the chain returned.
|
||||
aws-sigv4 = { version = "1", default-features = false, features = ["sign-http", "http1"] }
|
||||
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio", "sso"] }
|
||||
aws-credential-types = { version = "1", default-features = false }
|
||||
# `Identity` lives in aws-smithy-runtime-api; the SigV4 builder
|
||||
# accepts `&Identity`. Pinning the version explicitly avoids a
|
||||
# silent semver bump from the transitive dep tree.
|
||||
aws-smithy-runtime-api = { version = "1", default-features = false, features = ["client"] }
|
||||
# PR-D4: Vertex publisher path uses GCP Application Default Credentials
|
||||
# (ADC) → bearer token for the `Authorization: Bearer <token>` header.
|
||||
# `gcp_auth` resolves the chain (gcloud user creds, GCE/GKE metadata
|
||||
# server, service-account JSON, workload-identity federation) without
|
||||
# us baking provider-specific knowledge in. The token source is wrapped
|
||||
# in a `TokenSource` trait so tests inject a static-token mock.
|
||||
gcp_auth = "0.12"
|
||||
|
||||
|
||||
# ── Release profile — wheel size optimization ───────────────────────
|
||||
#
|
||||
# PyPI imposes a 10 GB cumulative storage limit per project. We hit it
|
||||
# at version 0.21.36 (191 versions × ~213 MB/release = 10.00 GB
|
||||
# exactly). Recent wheels were ~16-18 MB each, of which ~6.4 MB was
|
||||
# pure debug metadata (`.strtab` + `.symtab` ELF sections; uncovered
|
||||
# by post-mortem inspection of an actual production wheel).
|
||||
#
|
||||
# This profile shrinks each Linux wheel from ~18 MB → ~10-11 MB by:
|
||||
# * Stripping symbol/string tables (~6.4 MB direct savings)
|
||||
# * Link-time optimization across crate boundaries (~5-10% .text
|
||||
# savings via dead-code elim across the workspace)
|
||||
# * Single codegen unit (better inlining + dead-code elim, at the
|
||||
# cost of slightly slower release builds)
|
||||
#
|
||||
# We deliberately do NOT set ``panic = "abort"``. The proxy is a
|
||||
# long-lived async process — a single misbehaving request triggering
|
||||
# panic-abort would terminate the whole proxy and disconnect every
|
||||
# concurrent client. Accept the smaller savings; keep unwind behaviour.
|
||||
#
|
||||
# Estimated impact: 213 MB/release → ~130 MB/release. Buys ~30+ more
|
||||
# release slots within the 10 GB ceiling at the current release
|
||||
# cadence. Per-PyPI-version savings AND faster downloads for end
|
||||
# users. Tradeoff: release builds take ~30-50% longer due to
|
||||
# `codegen-units = 1` + LTO; acceptable for the size win.
|
||||
[profile.release]
|
||||
strip = "symbols"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# Fast-to-compile profile for CI test wheels. The shipped wheel uses
|
||||
# `release` (lto + codegen-units=1) for runtime/size; CI only needs a working
|
||||
# extension, so trade runtime perf for ~parallel, lto-free compilation. Used
|
||||
# via `maturin build --profile ci`. Does NOT affect `--release` builds.
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = false
|
||||
codegen-units = 256
|
||||
opt-level = 1
|
||||
strip = "none"
|
||||
debug = false
|
||||
incremental = false
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
ARG PYTHON_VERSION=3.13
|
||||
ARG UV_VERSION=0.11.18
|
||||
ARG DISTROLESS_IMAGE=gcr.io/distroless/python3-debian13
|
||||
ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages
|
||||
|
||||
# ---- Build stage: compile native extensions, build wheel ----
|
||||
FROM python:${PYTHON_VERSION}-slim AS builder
|
||||
|
||||
ARG UV_VERSION
|
||||
ARG PYTHON_SITE_PACKAGES
|
||||
ARG HEADROOM_BUILD_VERSION=""
|
||||
|
||||
# build-essential / g++ for any C extension wheels uv may need to build
|
||||
# from source. curl + ca-certificates are required by the rustup
|
||||
# bootstrap below. patchelf for maturin's wheel-link repair on linux.
|
||||
# No OpenSSL system deps required: the rustls-everywhere refactor
|
||||
# eliminated `openssl-sys` from our build tree by switching fastembed
|
||||
# to `hf-hub-rustls-tls` + `ort-download-binaries-rustls-tls`.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
g++ \
|
||||
curl \
|
||||
ca-certificates \
|
||||
patchelf \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m pip install --no-cache-dir uv==${UV_VERSION}
|
||||
|
||||
# Rust toolchain for the headroom._core extension. With single-wheel
|
||||
# architecture (post-#355), `pip install -e .` invokes maturin via
|
||||
# pyproject.toml's [build-system], which calls cargo. No more separate
|
||||
# headroom-core-py package.
|
||||
ENV CARGO_HOME=/usr/local/cargo \
|
||||
RUSTUP_HOME=/usr/local/rustup \
|
||||
PATH=/usr/local/cargo/bin:${PATH}
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal -c rustfmt -c clippy --default-toolchain 1.95.0
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy the full set of files maturin needs to build the wheel: the root
|
||||
# pyproject.toml + Cargo workspace + Rust crates + Python source. The
|
||||
# uv install builds + installs the wheel in one shot.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||
COPY crates/ crates/
|
||||
COPY headroom/ headroom/
|
||||
|
||||
ARG HEADROOM_EXTRAS=proxy,code
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=cache,target=/root/.cargo/registry \
|
||||
--mount=type=cache,target=/build/target \
|
||||
uv pip install --system ".[${HEADROOM_EXTRAS}]"
|
||||
|
||||
RUN --mount=type=bind,source=.,target=/context,readonly \
|
||||
HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" PYTHON_SITE_PACKAGES="${PYTHON_SITE_PACKAGES}" python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def git_revision(context: Path) -> str | None:
|
||||
git_dir = context / ".git"
|
||||
head_path = git_dir / "HEAD"
|
||||
if not head_path.exists():
|
||||
return None
|
||||
head = head_path.read_text(encoding="utf-8").strip()
|
||||
if head.startswith("ref: "):
|
||||
ref_name = head.removeprefix("ref: ").strip()
|
||||
ref_path = git_dir / ref_name
|
||||
if ref_path.exists():
|
||||
head = ref_path.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
packed_refs = git_dir / "packed-refs"
|
||||
if not packed_refs.exists():
|
||||
return None
|
||||
for line in packed_refs.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("#") or not line.strip():
|
||||
continue
|
||||
sha, _, name = line.partition(" ")
|
||||
if name.strip() == ref_name:
|
||||
head = sha
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return head[:12] if len(head) >= 7 and all(c in "0123456789abcdef" for c in head.lower()) else None
|
||||
|
||||
|
||||
def source_digest(root: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
inputs = (
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
"README.md",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"rust-toolchain.toml",
|
||||
"crates",
|
||||
"headroom",
|
||||
)
|
||||
for name in inputs:
|
||||
path = root / name
|
||||
if not path.exists():
|
||||
continue
|
||||
files = [path] if path.is_file() else sorted(p for p in path.rglob("*") if p.is_file())
|
||||
for file in files:
|
||||
digest.update(file.relative_to(root).as_posix().encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(file.read_bytes())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()[:12]
|
||||
|
||||
|
||||
build_version = os.environ["HEADROOM_BUILD_VERSION"].strip()
|
||||
if not build_version:
|
||||
print("no Headroom build version override provided; using installed package metadata")
|
||||
raise SystemExit(0)
|
||||
if build_version == "source-build":
|
||||
revision = git_revision(Path("/context"))
|
||||
build_version = (
|
||||
f"source-build+g{revision}"
|
||||
if revision
|
||||
else f"source-build+sha256.{source_digest(Path('/build'))}"
|
||||
)
|
||||
|
||||
package_dir = Path(os.environ["PYTHON_SITE_PACKAGES"]) / "headroom"
|
||||
(package_dir / "_build_info.py").write_text(
|
||||
"BUILD_VERSION = " + repr(build_version) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("baked Headroom build version: " + build_version)
|
||||
PY
|
||||
|
||||
# Build-stage smoke check: verify the extension loads end-to-end inside
|
||||
# the build image before we copy site-packages into the runtime image.
|
||||
# If this fails, the runtime image would fail Phase A0's fail-loud
|
||||
# startup check on every restart. Run from /tmp so cwd doesn't shadow
|
||||
# site-packages with /build/headroom/ (which has no _core.so since
|
||||
# maturin installed the .so into site-packages).
|
||||
RUN cd /tmp && python -c "from headroom._core import DiffCompressor, SmartCrusher; \
|
||||
print(f'build-stage rust core verify OK: {DiffCompressor.__name__}, {SmartCrusher.__name__}')"
|
||||
|
||||
# Build the native Rust reverse proxy binary and stage it for the runtime
|
||||
# images (issue #976). These images already run "the proxy"; bundling the
|
||||
# native `headroom-proxy` binary lets operators front the Python proxy with
|
||||
# the Rust SigV4 / live-zone compression path from the same image. The
|
||||
# binary is copied out of the cache-mounted target dir into a persistent
|
||||
# path so the COPY in the runtime stages can pick it up.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/build/target \
|
||||
cargo build --release --locked --bin headroom-proxy && \
|
||||
cp target/release/headroom-proxy /usr/local/bin/headroom-proxy
|
||||
|
||||
# ---- Runtime stage (python-slim): supports root/nonroot via build arg ----
|
||||
FROM python:${PYTHON_VERSION}-slim AS runtime-slim-base
|
||||
|
||||
ARG RUNTIME_USER=nonroot
|
||||
ARG RUNTIME_HOME=/home/nonroot
|
||||
ARG PYTHON_SITE_PACKAGES
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder ${PYTHON_SITE_PACKAGES} ${PYTHON_SITE_PACKAGES}
|
||||
COPY --from=builder /usr/local/bin/headroom /usr/local/bin/headroom
|
||||
# Native Rust reverse proxy binary (issue #976).
|
||||
COPY --from=builder /usr/local/bin/headroom-proxy /usr/local/bin/headroom-proxy
|
||||
|
||||
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 ${RUNTIME_USER}
|
||||
WORKDIR ${RUNTIME_HOME}
|
||||
|
||||
ENV HEADROOM_HOST=0.0.0.0 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Declare ~/.headroom as a volume so Docker (and ACA) can attach persistent
|
||||
# storage here. Bare `docker run` gets an anonymous volume as a fallback so
|
||||
# state is never silently written to the ephemeral container layer.
|
||||
# RUNTIME_HOME defaults to /home/nonroot (the published image default); pass
|
||||
# --build-arg RUNTIME_HOME=/root when building with RUNTIME_USER=root.
|
||||
VOLUME ${RUNTIME_HOME}/.headroom
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["curl", "--fail", "--silent", "http://127.0.0.1:8787/readyz"]
|
||||
|
||||
ENTRYPOINT ["headroom", "proxy"]
|
||||
CMD ["--host", "0.0.0.0", "--port", "8787"]
|
||||
|
||||
FROM ${DISTROLESS_IMAGE} AS runtime-slim
|
||||
|
||||
ARG RUNTIME_USER=nonroot
|
||||
ARG PYTHON_SITE_PACKAGES
|
||||
|
||||
COPY --from=builder ${PYTHON_SITE_PACKAGES} ${PYTHON_SITE_PACKAGES}
|
||||
# Native Rust reverse proxy binary (issue #976).
|
||||
COPY --from=builder /usr/local/bin/headroom-proxy /usr/local/bin/headroom-proxy
|
||||
|
||||
USER ${RUNTIME_USER}
|
||||
WORKDIR /app
|
||||
|
||||
ENV HEADROOM_HOST=0.0.0.0 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=${PYTHON_SITE_PACKAGES}
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/readyz', timeout=5)"]
|
||||
|
||||
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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 Headroom Contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Headroom Rust build targets. `just` is not installed on dev boxes; this
|
||||
# Makefile is the source of truth and is mirrored by .github/workflows/rust.yml.
|
||||
|
||||
SHELL := /bin/bash
|
||||
CARGO ?= cargo
|
||||
MATURIN ?= maturin
|
||||
PYTHON ?= python3
|
||||
FIXTURES ?= tests/parity/fixtures
|
||||
|
||||
.PHONY: help test test-parity bench build-proxy build-wheel fmt fmt-check lint clippy clean ci-precheck ci-precheck-rust ci-precheck-python ci-precheck-commitlint install-git-hooks verify-rust-core
|
||||
|
||||
help:
|
||||
@echo "Headroom Rust targets:"
|
||||
@echo " make test - cargo test --workspace"
|
||||
@echo " make test-parity - maturin develop + parity-run against fixtures"
|
||||
@echo " make bench - cargo bench --workspace"
|
||||
@echo " make build-proxy - release build + strip headroom-proxy, print size"
|
||||
@echo " make build-wheel - release wheel for headroom-py"
|
||||
@echo " make verify-rust-core - build + install + import-verify headroom._core"
|
||||
@echo " make fmt - cargo fmt --all"
|
||||
@echo " make fmt-check - cargo fmt --all -- --check"
|
||||
@echo " make lint - cargo clippy --workspace -- -D warnings"
|
||||
@echo " make clean - cargo clean"
|
||||
@echo ""
|
||||
@echo "E2e targets:"
|
||||
@echo " make build-e2e-wrap - build the wrap-e2e Docker image"
|
||||
@echo " make run-e2e-wrap - build + run the wrap-e2e Docker container"
|
||||
@echo ""
|
||||
@echo "Pre-push verification (run BEFORE git push to catch CI failures locally):"
|
||||
@echo " make ci-precheck - run all CI gates (rust + python + commitlint)"
|
||||
@echo " make ci-precheck-rust - cargo fmt --check + clippy + test"
|
||||
@echo " make ci-precheck-python - smart_crusher-affected python tests"
|
||||
@echo " make ci-precheck-commitlint - lint commits since origin/main"
|
||||
@echo " make install-git-hooks - install pre-commit, commit-msg, and pre-push hooks"
|
||||
|
||||
test:
|
||||
$(CARGO) test --workspace
|
||||
|
||||
test-parity:
|
||||
@if [ -z "$$VIRTUAL_ENV" ]; then \
|
||||
echo "error: activate a venv first (e.g. source .venv/bin/activate)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
$(MATURIN) develop -m crates/headroom-py/Cargo.toml
|
||||
$(CARGO) run -p headroom-parity -- run --fixtures $(FIXTURES)
|
||||
|
||||
bench:
|
||||
$(CARGO) bench --workspace
|
||||
|
||||
build-proxy:
|
||||
$(CARGO) build --release -p headroom-proxy
|
||||
@BIN=target/release/headroom-proxy; \
|
||||
if command -v strip >/dev/null 2>&1; then strip "$$BIN" || true; fi; \
|
||||
SIZE=$$(wc -c < "$$BIN"); \
|
||||
printf 'headroom-proxy: %s bytes (%.1f MiB)\n' "$$SIZE" "$$(echo "$$SIZE / 1048576" | bc -l)"
|
||||
|
||||
build-wheel:
|
||||
$(MATURIN) build --release -m crates/headroom-py/Cargo.toml
|
||||
|
||||
# Hotfix-A0: maturin-develop + symlink + import-verify in one shot. Run this
|
||||
# any time you suspect the proxy is silently falling back to Python-only
|
||||
# mode (Finding #2 in HEADROOM_PROXY_LOG_FINDINGS_2026_05_03.md). The
|
||||
# proxy itself runs the same check at lifespan startup; this target
|
||||
# exposes it as a developer-facing one-liner.
|
||||
verify-rust-core:
|
||||
@if [ -z "$$VIRTUAL_ENV" ]; then \
|
||||
echo "error: activate a venv first (e.g. source .venv/bin/activate)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
bash scripts/build_rust_extension.sh
|
||||
|
||||
fmt:
|
||||
$(CARGO) fmt --all
|
||||
|
||||
fmt-check:
|
||||
$(CARGO) fmt --all -- --check
|
||||
|
||||
clippy lint:
|
||||
$(CARGO) clippy --workspace -- -D warnings
|
||||
|
||||
clean:
|
||||
$(CARGO) clean
|
||||
|
||||
# ─── Pre-push CI gate ──────────────────────────────────────────────────────
|
||||
#
|
||||
# These targets run the same checks GitHub Actions runs, locally. The intent
|
||||
# is: if `make ci-precheck` is green, `git push` will not turn red. The
|
||||
# 2026-04-27 push surfaced five CI breaks (cargo fmt drift, x86_64-apple-
|
||||
# darwin wheel, headroom._core not built in test-extras + smoke-test,
|
||||
# commitlint footer-leading-blank). The first three are caught by the gates
|
||||
# below; the last two are caught by the workflow fixes themselves.
|
||||
#
|
||||
# Run before EVERY `git push`. Install the git hook (one-time) with:
|
||||
# make install-git-hooks
|
||||
|
||||
ci-precheck: ci-precheck-rust ci-precheck-python ci-precheck-commitlint
|
||||
@echo ""
|
||||
@echo "✅ ci-precheck PASSED — safe to push."
|
||||
|
||||
ci-precheck-rust:
|
||||
@echo "── ci-precheck-rust ────────────────────────────────────────────"
|
||||
$(CARGO) fmt --all -- --check
|
||||
$(CARGO) clippy --workspace -- -D warnings
|
||||
$(CARGO) test --workspace
|
||||
|
||||
# Mirrors the smart_crusher-affected test files we expect green on every
|
||||
# push. Builds the Rust extension first because most of these tests
|
||||
# instantiate `SmartCrusher`, which hard-imports `headroom._core`.
|
||||
ci-precheck-python:
|
||||
@echo "── ci-precheck-python ─────────────────────────────────────────"
|
||||
@if [ -z "$$VIRTUAL_ENV" ]; then \
|
||||
echo "error: activate a venv first (e.g. source .venv/bin/activate)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
bash scripts/build_rust_extension.sh
|
||||
$(PYTHON) -m pytest -q \
|
||||
tests/test_transforms/test_smart_crusher_bugs.py \
|
||||
tests/test_transforms/test_smart_crusher_rust_parity.py \
|
||||
tests/test_transforms/test_diff_compressor.py \
|
||||
tests/test_transforms/test_diff_compressor_rust_parity.py \
|
||||
tests/test_relevance.py \
|
||||
tests/test_relevance_extra.py \
|
||||
tests/test_ccr.py \
|
||||
tests/test_acceptance.py \
|
||||
tests/test_critical_fixes.py \
|
||||
tests/test_quality_retention.py \
|
||||
tests/test_toin_integration.py
|
||||
|
||||
# Lint commits since `origin/main`. Requires npx (Node 18+) on PATH.
|
||||
ci-precheck-commitlint:
|
||||
@echo "── ci-precheck-commitlint ─────────────────────────────────────"
|
||||
@if ! command -v npx >/dev/null 2>&1; then \
|
||||
echo "error: npx not on PATH (install Node 18+ to enable commitlint checks)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! git rev-parse --verify origin/main >/dev/null 2>&1; then \
|
||||
echo "error: origin/main not fetched (run 'git fetch origin main')"; \
|
||||
exit 1; \
|
||||
fi
|
||||
npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional -- \
|
||||
commitlint --from origin/main --to HEAD --config .commitlintrc.json
|
||||
|
||||
install-git-hooks:
|
||||
@scripts/install-git-hooks.sh
|
||||
|
||||
# ─── E2e Docker targets ────────────────────────────────────────────────────
|
||||
#
|
||||
# The wrap-e2e Dockerfile uses manylinux_2_28_x86_64 as its builder stage,
|
||||
# which only ships amd64 binaries. Pass --platform linux/amd64 explicitly
|
||||
# so the build works on Apple Silicon (requires QEMU emulation). On native
|
||||
# x86_64 hosts the flag is harmless and matches CI behaviour.
|
||||
|
||||
build-e2e-wrap:
|
||||
docker build --platform linux/amd64 -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
|
||||
|
||||
run-e2e-wrap: build-e2e-wrap
|
||||
docker run --rm headroom-wrap-e2e
|
||||
@@ -0,0 +1,43 @@
|
||||
Headroom
|
||||
Copyright 2025 Headroom Contributors
|
||||
|
||||
This product includes software developed by the Headroom Contributors.
|
||||
|
||||
Third-Party Licenses
|
||||
====================
|
||||
|
||||
This software uses the following third-party libraries:
|
||||
|
||||
tiktoken
|
||||
--------
|
||||
Copyright (c) 2022 OpenAI, Shantanu Jain
|
||||
Licensed under the MIT License
|
||||
https://github.com/openai/tiktoken
|
||||
|
||||
Pydantic
|
||||
--------
|
||||
Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors
|
||||
Licensed under the MIT License
|
||||
https://github.com/pydantic/pydantic
|
||||
|
||||
sentence-transformers (optional dependency)
|
||||
-------------------------------------------
|
||||
Copyright 2019 Nils Reimers
|
||||
Licensed under the Apache License 2.0
|
||||
https://github.com/UKPLab/sentence-transformers
|
||||
|
||||
Note: Some pretrained sentence-transformer models may have additional licensing
|
||||
restrictions based on their training data. Please verify model-specific licenses
|
||||
before commercial use.
|
||||
|
||||
FastAPI (optional dependency)
|
||||
-----------------------------
|
||||
Copyright (c) 2018 Sebastián Ramírez
|
||||
Licensed under the MIT License
|
||||
https://github.com/tiangolo/fastapi
|
||||
|
||||
NumPy (optional dependency)
|
||||
---------------------------
|
||||
Copyright (c) 2005-2024, NumPy Developers
|
||||
Licensed under the BSD 3-Clause License
|
||||
https://github.com/numpy/numpy
|
||||
@@ -0,0 +1,498 @@
|
||||
<div align="center"><pre>
|
||||
██╗ ██╗███████╗ █████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███╗ ███╗
|
||||
██║ ██║██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔═══██╗██╔═══██╗████╗ ████║
|
||||
███████║█████╗ ███████║██║ ██║██████╔╝██║ ██║██║ ██║██╔████╔██║
|
||||
██╔══██║██╔══╝ ██╔══██║██║ ██║██╔══██╗██║ ██║██║ ██║██║╚██╔╝██║
|
||||
██║ ██║███████╗██║ ██║██████╔╝██║ ██║╚██████╔╝╚██████╔╝██║ ╚═╝ ██║
|
||||
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝
|
||||
The context compression layer for AI agents
|
||||
</pre></div>
|
||||
|
||||
<p align="center"><strong>60–95% fewer tokens (for JSON data), 15-20% fewer tokens (for coding agents) · library · proxy · MCP · content-aware compressors · local-first · reversible</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/chopratejas/headroom/actions/workflows/ci.yml"><img src="https://github.com/chopratejas/headroom/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://app.codecov.io/gh/chopratejas/headroom"><img src="https://codecov.io/gh/chopratejas/headroom/graph/badge.svg" alt="codecov"></a>
|
||||
<a href="https://pypi.org/project/headroom-ai/"><img src="https://img.shields.io/pypi/v/headroom-ai.svg" alt="PyPI"></a>
|
||||
<a href="https://www.npmjs.com/package/headroom-ai"><img src="https://img.shields.io/npm/v/headroom-ai.svg" alt="npm"></a>
|
||||
<a href="https://huggingface.co/chopratejas/kompress-v2-base"><img src="https://img.shields.io/badge/model-Kompress--v2--base-yellow.svg" alt="Model: Kompress-v2-base"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License: Apache 2.0"></a>
|
||||
<a href="https://headroom-docs.vercel.app/docs"><img src="https://img.shields.io/badge/docs-online-blue.svg" alt="Docs"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://headroom-docs.vercel.app/docs">Docs</a> ·
|
||||
<a href="#get-started-60-seconds">Install</a> ·
|
||||
<a href="#proof">Proof</a> ·
|
||||
<a href="#agent-compatibility-matrix">Agents</a> ·
|
||||
<a href="https://discord.gg/yRmaUNpsPJ">Discord</a> ·
|
||||
<a href="llms.txt">llms.txt</a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>
|
||||
<b>AI agents / LLMs:</b> read <a href="llms.txt"><code>/llms.txt</code></a> here, or fetch <a href="https://headroom-docs.vercel.app/llms.txt">the live index</a> / <a href="https://headroom-docs.vercel.app/llms-full.txt">full docs blob</a>.
|
||||
</sub></p>
|
||||
|
||||
---
|
||||
<p align="center"><a href="https://trendshift.io/repositories/20881" target="_blank"><img src="https://trendshift.io/api/badge/repositories/20881" alt="chopratejas%2Fheadroom | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a></p>
|
||||
|
||||
Headroom compresses everything your AI agent reads — tool outputs, logs, RAG chunks, files, and conversation history — before it reaches the LLM. Same answers, fraction of the tokens.
|
||||
|
||||
<p align="center">
|
||||
<img src="HeadroomDemo-Fast.gif" alt="Headroom in action" width="820">
|
||||
<br/><sub>Live: 10,144 → 1,260 tokens — same FATAL found.</sub>
|
||||
</p>
|
||||
|
||||
## What it does
|
||||
|
||||
- **Library** — `compress(messages)` in Python or TypeScript, inline in any app
|
||||
- **Proxy** — `headroom proxy --port 8787`, zero code changes, any language
|
||||
- **Agent wrap** — `headroom wrap claude|codex|copilot|cursor|aider|opencode|cline|continue|goose|openhands|openclaw|vibe` in one command; undo with `headroom unwrap <tool>`
|
||||
- **MCP server** — `headroom_compress`, `headroom_retrieve`, `headroom_stats` for any MCP client
|
||||
- **Cross-agent memory** — shared store across Claude, Codex, Gemini, auto-dedup
|
||||
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored) or `CLAUDE.md` / `AGENTS.md` / `GEMINI.md`
|
||||
- **Output token reduction** — trims what the model *writes back* (not just what you send): drops ceremony/restated code and skips deep "thinking" on routine steps. See [Output token reduction](#output-token-reduction-cut-what-the-model-writes-back).
|
||||
- **Reversible (CCR)** — originals are cached for retrieval on demand
|
||||
|
||||
## How it works (30 seconds)
|
||||
|
||||
```
|
||||
Your agent / app
|
||||
(Claude Code, Cursor, Codex, LangChain, Agno, Strands, your own code…)
|
||||
│ prompts · tool outputs · logs · RAG results · files
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Headroom (runs locally — your data stays here) │
|
||||
│ ──────────────────────────────────────────────── │
|
||||
│ CacheAligner → ContentRouter → CCR │
|
||||
│ ├─ SmartCrusher (JSON) │
|
||||
│ ├─ CodeCompressor (AST) │
|
||||
│ └─ Kompress-v2-base (text, HF) │
|
||||
│ │
|
||||
│ Cross-agent memory · headroom learn · MCP │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│ compressed prompt + retrieval tool
|
||||
▼
|
||||
LLM provider (Anthropic · OpenAI · Bedrock · …)
|
||||
```
|
||||
|
||||
- **ContentRouter** — detects content type, selects the right compressor
|
||||
- **SmartCrusher / CodeCompressor / Kompress-v2-base** — compress JSON, AST, or prose
|
||||
- **CacheAligner** — stabilizes prefixes so provider KV caches actually hit
|
||||
- **CCR** — stores originals locally; LLM calls `headroom_retrieve` if it needs them
|
||||
|
||||
→ [Architecture](https://headroom-docs.vercel.app/docs/architecture) · [CCR reversible compression](https://headroom-docs.vercel.app/docs/ccr) · [Kompress-v2-base model card](https://huggingface.co/chopratejas/kompress-v2-base)
|
||||
|
||||
## Get started (60 seconds)
|
||||
|
||||
```bash
|
||||
# 1 — Install
|
||||
uv tool install "headroom-ai[all]" # Install `headroom` CLI as a global tool in self-contained virtual env
|
||||
pip install "headroom-ai[all]" # Python — ships the `headroom` CLI
|
||||
npm install headroom-ai # TypeScript SDK only — no `headroom` CLI
|
||||
|
||||
# 2 — Pick your mode (the `headroom` commands below come from the uv or pip install)
|
||||
headroom wrap claude # wrap a coding agent
|
||||
headroom proxy --port 8787 # drop-in proxy, zero code changes
|
||||
# or: from headroom import compress # inline library
|
||||
|
||||
# 3 — Verify setup and see the savings
|
||||
headroom doctor # health check — confirms routing is working
|
||||
headroom perf
|
||||
headroom dashboard # live savings dashboard (proxy must be running)
|
||||
```
|
||||
|
||||
To use headroom, it is recommended you launch a wrapped agent session each time so that all necessary setup is completed. When wrapping a coding agent, headroom starts a local proxy, sets up an MCP server that provides tools such as rtk and tokensave, and launches a coding agent session configured to proxy requests to headroom.
|
||||
|
||||
The `headroom` CLI ships **only** via the PyPI package. The npm `headroom-ai` is the TypeScript SDK — a library you import (`import { compress } from 'headroom-ai'`), not a CLI, so it provides no `headroom` command.
|
||||
|
||||
Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
|
||||
|
||||
### Codex / global install
|
||||
|
||||
If Codex or another MCP client cannot inherit a shell `PATH` reliably, install Headroom as a persistent uv tool and point the client at the absolute binary path:
|
||||
|
||||
```bash
|
||||
uv tool install "headroom-ai[all]"
|
||||
command -v headroom
|
||||
```
|
||||
|
||||
Then use the returned path in MCP config:
|
||||
|
||||
```toml
|
||||
[mcp_servers.headroom]
|
||||
command = "/absolute/path/from/command-v/headroom"
|
||||
args = ["mcp", "serve"]
|
||||
```
|
||||
|
||||
`command = "headroom"` only works when the client starts with a `PATH` that already includes the uv tool directory.
|
||||
|
||||
## Proof
|
||||
|
||||
**Savings on real agent workloads:**
|
||||
|
||||
| Workload | Before | After | Savings |
|
||||
|-------------------------------|-------:|-------:|--------:|
|
||||
| Code search (100 results) | 17,765 | 1,408 | **92%** |
|
||||
| SRE incident debugging | 65,694 | 5,118 | **92%** |
|
||||
| GitHub issue triage | 54,174 | 14,761 | **73%** |
|
||||
| Codebase exploration | 78,502 | 41,254 | **47%** |
|
||||
|
||||
**Accuracy preserved on standard benchmarks:**
|
||||
|
||||
| Benchmark | Category | N | Baseline | Headroom | Delta |
|
||||
|------------|----------|----:|---------:|---------:|------------|
|
||||
| GSM8K | Math | 100 | 0.870 | 0.870 | **±0.000** |
|
||||
| TruthfulQA | Factual | 100 | 0.530 | 0.560 | **+0.030** |
|
||||
| SQuAD v2 | QA | 100 | — | **97%** | 19% compression |
|
||||
| BFCL | Tools | 100 | — | **97%** | 32% compression |
|
||||
|
||||
Reproduce: `python -m headroom.evals suite --tier 1` · [Full benchmarks & methodology](https://headroom-docs.vercel.app/docs/benchmarks)
|
||||
|
||||
## Output token reduction (cut what the model writes back)
|
||||
|
||||
Everything above shrinks the prompt you **send**. But you also pay for every
|
||||
token the model **writes back** — and on Opus-class models output costs 5× input.
|
||||
A lot of that output is waste: "Great, let me…" preambles, re-printing code you
|
||||
just showed it, and deep "thinking" on routine steps like reading a file.
|
||||
|
||||
Headroom can trim that too, from the proxy, without you changing any code:
|
||||
|
||||
- **Verbosity steering** — appends a short "be terse, don't restate context"
|
||||
note to the end of the system prompt (so your prompt cache still hits).
|
||||
- **Effort routing** — when a turn is just the model resuming after a tool result
|
||||
(a file read, a passing test), it dials the model's thinking effort down. New
|
||||
questions and errors keep full effort.
|
||||
|
||||
Turn it on:
|
||||
|
||||
```bash
|
||||
export HEADROOM_OUTPUT_SHAPER=1 # off by default
|
||||
headroom proxy --port 8787
|
||||
```
|
||||
|
||||
> **Already running a proxy?** These switches are read *live* on every request,
|
||||
> so a proxy that `headroom wrap` **reused** (rather than started) would not see
|
||||
> a value you export afterwards — its environment was snapshotted at launch.
|
||||
> `headroom wrap` now hot-syncs your current settings to the running proxy via a
|
||||
> loopback `POST /admin/runtime-env`, so they take effect immediately with **no
|
||||
> restart** (no cold start, no dropped requests, no lost caches). Set them before
|
||||
> you `wrap`. On a shared proxy these overrides are global — the last explicit
|
||||
> setting wins.
|
||||
|
||||
**Learn the right terseness for you.** People don't *say* how terse they want
|
||||
answers — they *show* it (they interrupt long replies, or move on before they
|
||||
could have read them). `headroom learn --verbosity` reads your past sessions and
|
||||
picks the level automatically:
|
||||
|
||||
```bash
|
||||
headroom learn --verbosity # preview what it found (dry run)
|
||||
headroom learn --verbosity --apply # save it; the proxy uses it from now on
|
||||
```
|
||||
|
||||
**See how many output tokens you saved.** Output savings are *counterfactual* —
|
||||
we never see what the model *would* have written — so Headroom reports an honest
|
||||
**estimate with a confidence range**, never a made-up number:
|
||||
|
||||
```bash
|
||||
headroom output-savings
|
||||
# Reduction: 31.7% (95% CI 27.7% … 35.7%) [estimated]
|
||||
```
|
||||
|
||||
Want a *measured* number instead of an estimate? Leave 10% of conversations
|
||||
unshaped as a control group: `export HEADROOM_OUTPUT_HOLDOUT=0.1`. The dashboard
|
||||
shows an **Output Tokens Saved** card next to input compression, labelled
|
||||
`measured` or `estimated` with the confidence band.
|
||||
|
||||
→ Full write-up incl. the measurement methodology: [Output token reduction](https://headroom-docs.vercel.app/docs/savings)
|
||||
|
||||
<a href="https://www.star-history.com/?repos=chopratejas%2Fheadroom&type=date&legend=top-left">
|
||||
<picture>
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=chopratejas/headroom&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Agent compatibility matrix
|
||||
|
||||
| Agent | `headroom wrap` | Notes |
|
||||
|--------------|:---------------:|----------------------------------|
|
||||
| Claude Code | ✅ | `--memory` · `--code-graph` · `--1m` · `--tool-search` |
|
||||
| Codex | ✅ | shares memory with Claude |
|
||||
| Cursor | Manual setup | starts proxy and prints base URLs for Cursor settings |
|
||||
| Aider | ✅ | starts proxy + launches |
|
||||
| Copilot CLI | ✅ | starts proxy + launches |
|
||||
| OpenClaw | ✅ | installs as ContextEngine plugin |
|
||||
| OpenCode | ✅ | injects config · starts proxy + launches |
|
||||
| Cline | ✅ | starts proxy + injects config |
|
||||
| Continue | ✅ | starts proxy + injects config |
|
||||
| Goose | ✅ | starts proxy + launches |
|
||||
| OpenHands | ✅ | starts proxy + launches |
|
||||
| Mistral Vibe | ✅ | starts proxy + launches |
|
||||
| Cortex Code | Library only | 60–65% savings (library mode; no `wrap`) |
|
||||
|
||||
Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`.
|
||||
Undo durable wrapping with `headroom unwrap <tool>` (supports: `claude`, `copilot`, `codex`, `opencode`, `openclaw`).
|
||||
|
||||
### GitHub Copilot CLI subscription mode
|
||||
|
||||
Headroom can route GitHub Copilot CLI subscription traffic through the local proxy:
|
||||
|
||||
```bash
|
||||
headroom copilot-auth login
|
||||
headroom wrap copilot --subscription -- --model gpt-4o
|
||||
```
|
||||
|
||||
This lets Headroom intercept OpenAI-compatible Copilot CLI requests and apply the same proxy compression pipeline before forwarding to GitHub Copilot's hosted API. The wrapper exchanges Headroom's reusable GitHub OAuth token for Copilot's short-lived API token and prints the upstream endpoint as `COPILOT_PROVIDER_API_URL=...` during launch.
|
||||
|
||||
`headroom copilot-auth login` stores a Headroom-specific Copilot OAuth token.
|
||||
This avoids relying on generic GitHub or Copilot CLI tokens that can read
|
||||
Copilot account metadata but may still be rejected by Copilot's token-exchange
|
||||
endpoint.
|
||||
|
||||
For GitHub Enterprise Server or custom-domain Copilot deployments, set the
|
||||
deployment domain before launching:
|
||||
|
||||
```bash
|
||||
export GITHUB_COPILOT_ENTERPRISE_DOMAIN=ghe.example.com
|
||||
```
|
||||
|
||||
For GitHub.com Enterprise Cloud URLs such as
|
||||
`github.com/enterprises/your-enterprise`, do not set an enterprise-domain
|
||||
override. Headroom uses GitHub's normal token-exchange endpoint and the Copilot
|
||||
API endpoint advertised for the signed-in account.
|
||||
|
||||
Platform support note: macOS auth reuse via Copilot CLI Keychain storage has been smoke-tested. Windows Credential Manager, Linux Secret Service / `secret-tool`, and Docker/CI token-injection paths are implemented or planned as auth-discovery paths, but still need real OS validation before they should be considered fully vetted. For Docker and CI, prefer passing an explicit `GITHUB_COPILOT_TOKEN` or `GITHUB_COPILOT_GITHUB_TOKEN` rather than relying on host keychain access.
|
||||
|
||||
## When to use · When to skip
|
||||
|
||||
**Great fit if you…**
|
||||
- run AI coding agents daily and want savings without changing your code
|
||||
- work across multiple agents and want shared memory
|
||||
- need reversible compression — originals are retrievable via CCR within the configured TTL
|
||||
|
||||
**Skip it if you…**
|
||||
- only use a single provider's native compaction and don't need cross-agent memory
|
||||
- work in a sandboxed environment where local processes can't run
|
||||
|
||||
<details>
|
||||
<summary><b>Integrations — drop Headroom into any stack</b></summary>
|
||||
|
||||
| Your setup | Hook in with |
|
||||
|------------------------|------------------------------------------------------------------|
|
||||
| Any Python app | `compress(messages, model=…)` |
|
||||
| Any TypeScript app | `await compress(messages, { model })` |
|
||||
| Anthropic / OpenAI SDK | `withHeadroom(new Anthropic())` · `withHeadroom(new OpenAI())` |
|
||||
| Vercel AI SDK | `wrapLanguageModel({ model, middleware: headroomMiddleware() })` |
|
||||
| LiteLLM | `litellm.callbacks = [HeadroomCallback()]` |
|
||||
| LangChain | `HeadroomChatModel(your_llm)` |
|
||||
| Agno | `HeadroomAgnoModel(your_model)` |
|
||||
| Strands | [Strands guide](https://headroom-docs.vercel.app/docs/strands) |
|
||||
| ASGI apps | `app.add_middleware(CompressionMiddleware)` |
|
||||
| Multi-agent | `SharedContext().put / .get` |
|
||||
| MCP clients | `headroom mcp install` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What's inside</b></summary>
|
||||
|
||||
- **SmartCrusher** — universal JSON: arrays of dicts, nested objects, mixed types.
|
||||
- **CodeCompressor** — AST-aware for Python, JS/TS, Go, Rust, Java, C/C++, Perl.
|
||||
- **Kompress-v2-base** — our HuggingFace model, trained on agentic traces.
|
||||
- **Image compression** — 40–90% reduction via trained ML router.
|
||||
- **CacheAligner** — stabilizes prefixes so Anthropic/OpenAI KV caches actually hit.
|
||||
- **Live-zone compression** — compresses only new bytes (fresh tool output, latest turn); frozen prefix stays byte-identical so provider cache is not busted. History is never dropped.
|
||||
- **CCR** — reversible compression; LLM retrieves originals on demand.
|
||||
- **Cross-agent memory** — shared store, agent provenance, auto-dedup.
|
||||
- **SharedContext** — compressed context passing across multi-agent workflows.
|
||||
- **`headroom learn`** — plugin-based failure mining for Claude, Codex, Gemini.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Pipeline internals</b></summary>
|
||||
|
||||
Headroom exposes one stable request lifecycle across `compress()`, the SDK, and the proxy:
|
||||
|
||||
`Setup` → `Pre-Start` → `Post-Start` → `Input Received` → `Input Cached` → `Input Routed` → `Input Compressed` → `Input Remembered` → `Pre-Send` → `Post-Send` → `Response Received`
|
||||
|
||||
- **Transforms** do the work: CacheAligner → ContentRouter → SmartCrusher / CodeCompressor / Kompress-base (live-zone only; IntelligentContext and RollingWindow were retired in PR-B1).
|
||||
- **Pipeline extensions** observe or customize lifecycle stages via `on_pipeline_event(...)`.
|
||||
- **Compression hooks** sit alongside the canonical lifecycle as an additional extension seam.
|
||||
- **Proxy extensions** remain the server/app integration seam for ASGI middleware, routes, and startup policy.
|
||||
|
||||
Provider and tool-specific behavior lives under `headroom/providers/` so core orchestration stays focused on lifecycle, sequencing, and policy.
|
||||
|
||||
- **CLI/tool slices**: `headroom/providers/claude`, `copilot`, `codex`, `openclaw`
|
||||
- **Provider runtime slices**: `headroom/providers/claude`, `gemini`, plus shared backend/runtime dispatch in `headroom/providers/registry.py`
|
||||
- **Core files stay orchestration-first**: `wrap.py`, `client.py`, `cli/proxy.py`, and `proxy/server.py` delegate provider-specific env shaping, API target normalization, backend selection, and transport dispatch.
|
||||
|
||||
</details>
|
||||
|
||||
## Headroom for teams
|
||||
|
||||
Headroom OSS is built for **individual developers**: run `headroom proxy` or `headroom wrap` on your laptop and start cutting tokens in minutes — free, local-first, your data never leaves your machine.
|
||||
|
||||
Running it across a **whole engineering org** is a different job: a shared, always-on deployment; centralized config and version rollout; org-wide savings dashboards; SSO and access controls; air-gapped / VPC installs; and someone to call when it matters. That's what we help companies with — self-hosted with support, or fully managed.
|
||||
|
||||
**If your team is spending real money on LLM tokens** — Claude Code, Codex, Cursor, or agents running in CI — **and you want those savings across everyone, not just one laptop:**
|
||||
|
||||
→ Email **[hello@headroomlabs.ai](mailto:hello@headroomlabs.ai)** with your stack and rough monthly LLM spend, and we'll help you roll Headroom out across your organization.
|
||||
|
||||
Everything in this repo stays open source (Apache 2.0). The managed offering is simply for teams that would rather have it deployed, supported, and scaled for them.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install "headroom-ai[all]" # Python, everything — includes the `headroom` CLI
|
||||
npm install headroom-ai # TypeScript SDK (library only — no `headroom` CLI)
|
||||
docker pull ghcr.io/chopratejas/headroom:latest
|
||||
```
|
||||
|
||||
Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-v2-base), `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
|
||||
|
||||
> **Note**: `[all]` covers the core stack but excludes framework adapters. Install them separately: `pip install "headroom-ai[langchain]"` (also `[agno]`, `[strands]`, `[anyllm]`, `[bedrock]`).
|
||||
|
||||
Using `pipx`? Choose a supported interpreter explicitly:
|
||||
|
||||
```bash
|
||||
pipx install --python python3.13 "headroom-ai[all]"
|
||||
```
|
||||
|
||||
> **Pick 3.13 if you want dollar savings.** The dashboard's *Proxy $ Saved* tile prices compression with [LiteLLM](https://github.com/BerriAI/litellm), and LiteLLM can't be installed on Python 3.14+. On 3.14 token savings still track, but the dollar figure stays `$0.00`. If you already installed on 3.14, switch with `pipx reinstall headroom-ai --python python3.13` and restart the proxy.
|
||||
|
||||
→ [Installation guide](https://headroom-docs.vercel.app/docs/installation) — Docker tags, persistent service, PowerShell, devcontainers.
|
||||
|
||||
> **CPU requirement (x86/x86_64):** the ONNX-backed features — Magika content
|
||||
> detection and embedding relevance — use a precompiled ONNX Runtime that needs
|
||||
> **AVX2**. On x86 hosts without AVX2 (some Docker/QEMU setups and older cloud
|
||||
> VMs) Headroom automatically falls back to its non-ONNX paths (BM25 relevance,
|
||||
> heuristic detection) rather than crashing. `arm64`/Apple Silicon needs no AVX2.
|
||||
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
headroom update # detects pip / pipx / uv tool and upgrades in place
|
||||
headroom update --check # report the latest release without upgrading
|
||||
headroom update --pre # include pre-releases
|
||||
```
|
||||
|
||||
`headroom update` figures out how Headroom was installed (pip/venv, `pip --user`,
|
||||
pipx, uv tool) and runs the matching upgrade across macOS, Linux, and Windows.
|
||||
For git checkouts, editable installs, Docker images, and externally-managed
|
||||
system Pythons (PEP 668) it prints the correct manual step instead of guessing.
|
||||
|
||||
The proxy also shows a one-line "update available" notice on startup. It checks
|
||||
PyPI at most once a day, in the background, and never blocks. Opt out with
|
||||
`HEADROOM_UPDATE_CHECK=off` (also skipped in `--stateless` mode and CI).
|
||||
|
||||
### Corporate / SSL-inspection environments
|
||||
|
||||
If `pip install "headroom-ai[all]"` fails with `CERTIFICATE_VERIFY_FAILED`
|
||||
(`unable to get local issuer certificate`), your network uses **SSL inspection** — a MITM
|
||||
proxy presenting a company-issued CA. The build backend (`maturin`) downloads `rustup` over a
|
||||
connection your TLS stack doesn't trust. **Install Rust first** so the build doesn't fetch it:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh && rustup default stable
|
||||
# Windows
|
||||
winget install Rustlang.Rustup && rustup default stable
|
||||
```
|
||||
|
||||
Restart your shell, then `pip install "headroom-ai[all]"`. A prebuilt wheel avoids the Rust
|
||||
build entirely where available: `pip install --only-binary headroom-ai headroom-ai`. Prebuilt
|
||||
wheels are published for Windows (`win_amd64`), Linux (`x86_64` / `aarch64`), and macOS
|
||||
(Apple Silicon and Intel), so installs on those platforms never need a local Rust toolchain — the
|
||||
Rust-first dance above is only for the platform-independent sdist fallback when no wheel matches.
|
||||
|
||||
Two runtime assets are fetched over TLS; if they are blocked, trust your corporate CA via
|
||||
`REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE`:
|
||||
|
||||
- **`cdn.pyke.io`** — the ONNX Runtime for the Rust core. Alternatively pre-provide it with
|
||||
`ORT_STRATEGY=system` and `ORT_LIB_LOCATION=/path/to/onnxruntime`.
|
||||
- **`huggingface.co`** — the `kompress-base` compression model. Pre-download it and run with
|
||||
`HF_HUB_OFFLINE=1`, or set `HF_ENDPOINT` to a trusted mirror.
|
||||
|
||||
Running with compression disabled (pure gateway) requires neither asset.
|
||||
|
||||
#### "Basic Constraints of CA cert not marked critical" (Python 3.13+ strict mode)
|
||||
|
||||
A **different** failure from the one above. If TLS fails with:
|
||||
|
||||
```
|
||||
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
|
||||
Basic Constraints of CA cert not marked critical
|
||||
```
|
||||
|
||||
then the corporate CA *is* found and trusted — adding it to a CA bundle changes nothing.
|
||||
Python 3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which enforces RFC 5280
|
||||
§4.2.1.9: a CA cert's `basicConstraints` must be marked *critical*. Inspection roots like
|
||||
Zscaler set `CA:TRUE` without the critical bit, so the chain is rejected.
|
||||
|
||||
Set **`HEADROOM_TLS_STRICT=0`** to clear *only* the strict flag from every TLS context
|
||||
Headroom controls — the proxy's httpx upstream client **and** the urllib3/`huggingface_hub`
|
||||
path used for model downloads. Chain validation, signature, expiry, and hostname checks all
|
||||
stay on; this is strictly narrower than disabling verification.
|
||||
|
||||
```bash
|
||||
HEADROOM_TLS_STRICT=0 headroom proxy --port 8787
|
||||
```
|
||||
|
||||
The Rust core's ONNX download (`cdn.pyke.io`) uses a separate TLS stack (rustls / OS trust
|
||||
store), unaffected by `HEADROOM_TLS_STRICT`. On Windows the corporate root must be in the
|
||||
**machine** certificate store (browsers already trust it there); or pre-provision ONNX
|
||||
Runtime with `ORT_STRATEGY=system` + `ORT_LIB_LOCATION=/path/to/onnxruntime` to skip the
|
||||
download entirely.
|
||||
|
||||
## headroom learn
|
||||
|
||||
<p align="center">
|
||||
<img src="headroom_learn.gif" alt="headroom learn in action" width="720">
|
||||
</p>
|
||||
|
||||
`headroom learn` — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored; use `--target CLAUDE.md` for the shared team file) / `AGENTS.md` / `GEMINI.md`.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Start here | Go deeper |
|
||||
|-------------------------------------------------------------------------------|------------------------------------------------------------------------------------|
|
||||
| [Quickstart](https://headroom-docs.vercel.app/docs/quickstart) | [Architecture](https://headroom-docs.vercel.app/docs/architecture) |
|
||||
| [Proxy](https://headroom-docs.vercel.app/docs/proxy) | [How compression works](https://headroom-docs.vercel.app/docs/how-compression-works) |
|
||||
| [MCP tools](https://headroom-docs.vercel.app/docs/mcp) | [CCR — reversible compression](https://headroom-docs.vercel.app/docs/ccr) |
|
||||
| [Memory](https://headroom-docs.vercel.app/docs/memory) | [Cache optimization](https://headroom-docs.vercel.app/docs/cache-optimization) |
|
||||
| [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning) | [Benchmarks](https://headroom-docs.vercel.app/docs/benchmarks) |
|
||||
| [Configuration](https://headroom-docs.vercel.app/docs/configuration) | [Limitations](https://headroom-docs.vercel.app/docs/limitations) |
|
||||
| [Persistent installs](https://headroom-docs.vercel.app/docs/persistent-installs) (`headroom init` / `headroom install apply`) | [Savings analytics](https://headroom-docs.vercel.app/docs/savings) (`headroom savings` / `headroom perf` / `headroom doctor`) |
|
||||
|
||||
## Compared to
|
||||
|
||||
Headroom runs **locally**, covers **every** content type, works with every major framework, and is **reversible**.
|
||||
|
||||
| | Scope | Deploy | Local | Reversible |
|
||||
|------------------------------------------------------------------------------|------------------------------------------------|------------------------------------|:-----:|:----------:|
|
||||
| **Headroom** | All context — tools, RAG, logs, files, history | Proxy · library · middleware · MCP | Yes | Yes |
|
||||
| [RTK](https://github.com/rtk-ai/rtk) | CLI command outputs | CLI wrapper | Yes | No |
|
||||
| [lean-ctx](https://github.com/yvgude/lean-ctx) | Tool output, files, shell, history | Proxy · library · middleware · MCP · CLI | Yes | Yes |
|
||||
| [Compresr](https://compresr.ai), [Token Co.](https://thetokencompany.ai) | Text sent to their API | Hosted API call | No | No |
|
||||
| OpenAI Compaction | Conversation history | Provider-native | No | No |
|
||||
|
||||
> **Attribution.** Headroom ships with the excellent [RTK](https://github.com/rtk-ai/rtk) binary for shell-output rewriting — `git show --short`, scoped `ls`, summarized installers. Huge thanks to the RTK team; their tool is a first-class part of our stack, and Headroom compresses everything downstream of it. Headroom can also use [lean-ctx](https://github.com/yvgude/lean-ctx) as the selected CLI context tool; set `HEADROOM_CONTEXT_TOOL=lean-ctx` before running `headroom wrap ...`.
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
git clone https://github.com/chopratejas/headroom.git && cd headroom
|
||||
uv sync --extra dev && uv run pytest
|
||||
```
|
||||
|
||||
Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j). See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Community
|
||||
|
||||
- **[Discord](https://discord.gg/yRmaUNpsPJ)** — questions, feedback, war stories.
|
||||
- **[Kompress-v2-base on HuggingFace](https://huggingface.co/chopratejas/kompress-v2-base)** — the model behind our text compression.
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`headroomlabs-ai/headroom`
|
||||
- 原始仓库:https://github.com/headroomlabs-ai/headroom
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,53 @@
|
||||
# 00 — Overview & Wrong Mental Model
|
||||
|
||||
## Executive summary
|
||||
|
||||
Headroom is built on the wrong mental model: **"compression means choosing what to drop from conversation history."** The flagship `IntelligentContextManager` (ICM) tokenizes the entire `messages` array, scores each message for importance, and removes old messages until the budget is hit. It has been wired into the Rust proxy on `/v1/messages` with `frozen_message_count: 0` hardcoded — so every compression event drops messages from index 0, busting the Anthropic prompt cache for every customer that triggers it.
|
||||
|
||||
The correct mental model — confirmed by an authoritative engineering guide and ten parallel deep-audit subagents — is the opposite: **"passthrough is sacred; compress only the live zone, type-aware, hash-keyed, position-preserving, with side-channel metadata."** The cache hot zone (system prompt, tools, old turns, reasoning/thinking/redacted/compaction items) is **never** touched.
|
||||
|
||||
The audit found:
|
||||
|
||||
- **5 top-tier cache-killer bugs** all stemming from the wrong model
|
||||
- **~10 K LOC of architectural over-build** (ICM + scoring + relevance + rolling-window + progressive-summarizer + tool-crusher + cache-aligner rewrite path + most of `crates/headroom-core/src/{context,scoring,relevance}/`)
|
||||
- **Wire-format gaps** in the streaming SSE parser (missing `thinking_delta`, `signature_delta`, `citations_delta`; UTF-8-split corruption; single-`\n` SSE split bugs in fallback paths)
|
||||
- **Bedrock/Vertex parity is fake** — a lossy LiteLLM Anthropic-to-OpenAI conversion drops `thinking`, `redacted_thinking`, `document`, `search_result`, `image`, `server_tool_use`, `mcp_tool_use` blocks
|
||||
- **No tool-definition normalization** anywhere
|
||||
- **No auth-mode awareness** — PAYG, OAuth, and subscription CLIs all get the same policy and the same fingerprint-leaking re-serialization
|
||||
- **`X-Headroom-*` request headers leak upstream**, plus `anthropic-beta` mutation and `OpenAI-Beta` auto-injection — fingerprint-class subscription-revocation risks
|
||||
- **CCR markers** are computed but never injected into the outgoing request body in the Rust path; the `ccr_retrieve` tool flips on/off per request — busts the tools array on every state change
|
||||
|
||||
## What changes
|
||||
|
||||
The realignment is structured in 9 phases, 40 PRs, ~13 weeks sequential or ~8 weeks with parallel work:
|
||||
|
||||
- **Phase A — Lockdown (1 week):** stop the cache bleeding immediately. Make `/v1/messages` compression a passthrough; stop mutating the system prompt; switch Python forwarders from `httpx ... json=body` (re-serializes) to `httpx ... content=raw_bytes`; honor customer-set `cache_control` markers in Rust; strip `x-headroom-*` from upstream-bound headers; pin `anthropic-beta` order and make it session-sticky; add a SHA-256 byte-faithful round-trip test.
|
||||
- **Phase B — Live-zone engine (2 weeks):** delete ICM, scoring, relevance, rolling-window, progressive-summarizer, tool-crusher (~10 K LOC). Build a live-zone-only block dispatcher in Rust that runs SmartCrusher / LogCompressor / DiffCompressor / SearchCompressor / KompressCompressor on the latest user message content + latest tool_result + latest function_call_output + latest local_shell_call_output. Token-validate every compression with fallback. CCR hardens: persistent backend + always-on `ccr_retrieve` tool registration.
|
||||
- **Phase C — Rust proxy paths (3 weeks):** byte-level SSE parser with full state machine; `/v1/chat/completions`, `/v1/responses` (HTTP and streaming) handlers; per-item-type passthrough preservation (V4A patches, `local_shell_call.action.command` argv, Codex `phase` field, MCP items, `compaction`).
|
||||
- **Phase D — Bedrock/Vertex native (2 weeks):** delete the LiteLLM lossy converter; build native `/model/.../invoke` (AWS) and `/v1beta1/projects/.../publishers/anthropic/.../streamRawPredict` (GCP) routes with SigV4 + ADC signing. Cache fidelity restored on Bedrock/Vertex traffic.
|
||||
- **Phase E — Phase 3 cache stabilization (1 week):** sort tool array deterministically; sort JSON Schema keys recursively; auto-place up to 4 `cache_control` breakpoints (Anthropic); auto-inject `prompt_cache_key` (OpenAI); volatile-content detector with customer warning (no rewrite); cache-bust drift telemetry.
|
||||
- **Phase F — Auth-mode policy (1 week):** `classify_auth_mode(headers)` helper returning `payg | oauth | subscription`; per-mode compression policy gates; TOIN aggregation key extended to `(auth_mode, model_family, structure_hash)`; conditional `X-Forwarded-*` headers in Rust.
|
||||
- **Phase G — RTK + observability (1 week):** extend wrap CLIs (cline, continue, goose, openhands); wire the dead `tokens_saved_rtk` field; per-invocation RTK Prometheus metrics.
|
||||
- **Phase H — Python retirement (2 weeks):** delete `headroom/proxy/server.py`, all handlers, `responses_converter.py`, `memory_handler.py`, `memory_tool_adapter.py`, `batch.py`, `semantic_cache.py`, all of `headroom/transforms/*` Python (per Phase B); keep CLI wrappers, RTK installer, evals, learn, memory writers, tokenizers, TOIN.
|
||||
- **Phase I — Test infra (continuous, parallel):** SHA-256 round-trip tests; SSE corner-case fixtures (UTF-8 split, ping, all delta types, `[DONE]`, mid-stream error); property tests (no-panic SSE parser, tokens-non-increasing compression); cache-hit-rate continuous metric; promote `ccr` / `log_compressor` / `cache_aligner` parity comparators from `Skipped` stubs to real; make `make test-parity` a per-PR gate.
|
||||
|
||||
## Top 5 wrong assumptions
|
||||
|
||||
1. **"Compression means choosing what to drop from history."** Implemented as ICM + DropByScoreStrategy + MessageScorer + relevance + scoring + rolling-window + progressive-summarizer. Fix: retire entirely; compress live-zone content only.
|
||||
2. **"TOIN can influence per-request compression decisions."** `headroom/telemetry/toin.py:853-927` mutates pattern state during a call and returns hints that bias the same-input-bytes decision. Fix: strict observation-only; recommendations published between deploys.
|
||||
3. **"CCR can mutate the cache hot zone (tools array, system prompt) on demand."** `headroom/ccr/tool_injection.py:302-328` only adds `ccr_retrieve` when content was compressed — tools list flips between requests. `cache_aligner.py:160-262` and `headroom/proxy/server.py:1051` rewrite the system prompt. Fix: register `ccr_retrieve` on every request; route memory injection to the live zone tail; delete the cache_aligner rewrite path.
|
||||
4. **"Summarizing past turns is a strategy."** `intelligent_context.py:316-353` SUMMARIZE replaces messages with a single summary at the same position — head modification. Fix: delete; offer compaction only as an explicit customer-initiated action.
|
||||
5. **"ToolCrusher operates on every tool message in history without a frozen check."** `headroom/transforms/tool_crusher.py:106` iterates all tool messages. Fix: delete; ContentRouter covers the use case correctly.
|
||||
|
||||
## What's preserved
|
||||
|
||||
Per your direction:
|
||||
- **TOIN** (Tool Output Intelligence Network) — observation-only refactor; per-tenant key
|
||||
- **CCR** (Compress-Cache-Retrieve) — persistent backend + always-on tool
|
||||
- **Kompress-base** — plain-text §8.6 compressor; stays in Python now, Rust port via `ort` crate later
|
||||
- **ContentRouter** — Python ~2150 LOC, the architecturally correct piece (NOTE: earlier project memory said 53 K lines — that was wrong by 25×; the file is fine)
|
||||
- All per-type compressors: SmartCrusher (Rust 25 files), CodeCompressor, LogCompressor, SearchCompressor, DiffCompressor
|
||||
|
||||
## What's deleted
|
||||
|
||||
~25 K LOC across two languages. See [01-bug-list.md](./01-bug-list.md) §6 for the full retirement list with file:line evidence.
|
||||
@@ -0,0 +1,421 @@
|
||||
# 01 — Comprehensive Bug & Gap List
|
||||
|
||||
Ranked P0 (cache-killer) → P5 (long tail). Every entry has: title, file:line, evidence, guide §, fix, ROI estimate.
|
||||
|
||||
Sources: 10 parallel deep-audit subagents (Rust proxy passthrough; Rust compression correctness; Python proxy + bridges; prefix cache safety; streaming + wire-format; RTK + tests/parity; over-engineering; OpenAI long-tail + Bedrock; Headroom-side injections; auth-mode handling).
|
||||
|
||||
---
|
||||
|
||||
## P0 — Cache-killer smoking guns (every customer affected)
|
||||
|
||||
These bugs collapse Anthropic prompt-cache hit rate toward 0% for any traffic that triggers them. Fix in Phase A.
|
||||
|
||||
### P0-1. System prompt mutated by `.strip()` and memory-context append
|
||||
- **File:** `headroom/proxy/server.py:1050-1058`; `headroom/proxy/handlers/openai.py:1212`
|
||||
- **Evidence:** `body["system"] = (existing_system + "\n\n" + context).strip()` — strips whitespace and appends dynamic memory context to the cache hot zone on every memory-enabled call.
|
||||
- **Guide:** §1.11 (whitespace fidelity), §6.3 #10 (tiny system prompt edits invalidate cache), §10.1 (system = always cache hot).
|
||||
- **Fix:** Remove `_inject_system_context` path; route memory context to the **first block of the latest user message** (live zone). The existing `_append_context_to_latest_non_frozen_user_turn` already does this — make it the only path.
|
||||
- **ROI:** Restores cache hits for ~all memory-enabled traffic.
|
||||
- **Phase A → PR-A2.**
|
||||
|
||||
### P0-2. Every Python forwarder re-serializes JSON via `httpx ... json=body`
|
||||
- **File:** `headroom/proxy/server.py:1088, 1090`; `headroom/proxy/handlers/streaming.py:651`; `headroom/proxy/handlers/openai.py:2392-2397`; `headroom/proxy/handlers/batch.py:344`
|
||||
- **Evidence:** httpx default encoder calls `json.dumps(body, separators=(", ", ": "), ensure_ascii=True)`. Inbound bytes use `,`/`:` and raw UTF-8 in user content; outbound bytes use `, `/`: ` and `\uXXXX` escapes. Bytes never reach upstream byte-equal to bytes that arrived.
|
||||
- **Guide:** §1.9 (the single most expensive proxy mistake), §1.10 (numeric precision), §1.11 (whitespace fidelity).
|
||||
- **Fix:** Switch every forwarder to `httpx ... content=raw_bytes_modified_in_place`. Keep the original `await request.body()` bytes; if a transform mutated the body, re-serialize with `separators=(",", ":")` + `ensure_ascii=False`. Better: surgical byte-fragment replacement on `messages` only, leaving the envelope's bytes untouched.
|
||||
- **ROI:** Restores cache hits for **all** Python-forwarded traffic.
|
||||
- **Phase A → PR-A3.**
|
||||
|
||||
### P0-3. Rust proxy ignores customer `cache_control` markers
|
||||
- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:151-156`
|
||||
- **Evidence:** `frozen_message_count: 0` hardcoded with `TODO: detect provider prefix-cached messages from the request. Until we wire that detection, we treat the whole list as droppable.` Combined with ICM, every compression event drops messages from index 0.
|
||||
- **Guide:** §2.19 (up to 4 cache_control markers), §6.2 (cache breakpoints define the prefix).
|
||||
- **Fix:** Walk `messages[*].content[*].cache_control`, `system[*].cache_control`, `tools[*].cache_control`; set `frozen_message_count` to the highest message index that contains a cache_control marker.
|
||||
- **ROI:** Restores cache hits for **all** clients using Anthropic prompt caching (which is virtually all production Anthropic traffic).
|
||||
- **Phase A → PR-A4.**
|
||||
|
||||
### P0-4. ICM compresses by dropping messages from cache hot zone (wrong scope)
|
||||
- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:146-157`; `crates/headroom-core/src/context/strategy/drop_by_score.rs:64-80`; `crates/headroom-core/src/context/manager.rs`; `headroom/transforms/intelligent_context.py:354-450`
|
||||
- **Evidence:** ICM with default `keep_last_turns: 2` is allowed to drop any message older than the last two turns. Combined with P0-3, this is a 100%-likely cache-buster on any conversation with ≥3 user turns.
|
||||
- **Guide:** §6.5 (live zone vs hot zone), §10.1 ("Old conversation turns ... never compress"), §6.3 #11 ("Truncation/summarization at the head"), §7.2 (append-only compression).
|
||||
- **Fix:** Delete ICM. Replace with live-zone-only block-level compression. Phase B builds the replacement.
|
||||
- **ROI:** Eliminates the largest single class of cache-bust events.
|
||||
- **Phase A → PR-A1 (stop calling ICM); Phase B → PR-B1 (delete ICM).**
|
||||
|
||||
### P0-5. Numeric precision lost via `serde_json::Value` round-trip
|
||||
- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:91, 172`
|
||||
- **Evidence:** Body parsed into `serde_json::Value` and re-serialized via `serde_json::to_vec(&parsed)`. `Value::Number` is `i64|u64|f64` so any `1.0` round-trips to `1`; large integers above 2^53 lose precision. `Cargo.toml:34` enables `preserve_order` only — no `arbitrary_precision`, no `RawValue`.
|
||||
- **Guide:** §1.10.
|
||||
- **Fix:** Add `arbitrary_precision` and `raw_value` features to `serde_json`. Use `&RawValue` for `messages[*]` so individual messages forward as exact byte copies. Strategy outputs only need to be "drop this index" or "replace this block's content."
|
||||
- **ROI:** Closes the second-largest re-serialization byte-drift class.
|
||||
- **Phase A → PR-A4 (jointly with P0-3).**
|
||||
|
||||
### P0-6. Memory tool injection toggles tools list and mutates `anthropic-beta`
|
||||
- **File:** `headroom/proxy/memory_handler.py:389-398`; `headroom/proxy/handlers/anthropic.py:1147-1171`
|
||||
- **Evidence:** Memory adds `memory_save`, `memory_search` tools to `body["tools"]` only when memory is enabled for the request. Mid-session config flicker → tool set changes → cache busts (§6.3 #2). Same code mutates `anthropic-beta` adding `context-management-2025-06-27` when injection happens (§6.3 #6).
|
||||
- **Fix:** Make memory tool injection **session-sticky**: once injected, always inject for the lifetime of the session. Pin `anthropic-beta` order; never reorder tokens within the comma-list.
|
||||
- **ROI:** Eliminates mid-session cache busts.
|
||||
- **Phase A → PR-A6, PR-A7.**
|
||||
|
||||
### P0-7. `responses_converter.py` drops Codex `phase` field and corrupts multi-text-part rebuild
|
||||
- **File:** `headroom/proxy/responses_converter.py:94, 221-256`
|
||||
- **Evidence:** `phase` field is dropped on the Chat-Completions trip (line 94 maps `role` only); only `copy.copy(original)` accidentally retains it on the rebuild path. Multi-text-part input messages get corrupted: `_extract_text_from_parts` joins with `\n`, `_reconstruct_item:254-256` puts the concatenated text into the first part only and leaves parts 1..N as-is, doubling content.
|
||||
- **Guide:** §4.5 (preserve `phase` exactly), §7.9 (position preservation).
|
||||
- **Fix:** Stash `phase` and restore in `_reconstruct_item`. Rebuild text parts by index, replacing each part's text in place. Better: in Phase C, port `/v1/responses` to Rust and never decompose item structure for compression.
|
||||
- **Phase A → PR-A8 (Python hotfix), Phase C → PR-C5 (full rebuild).**
|
||||
|
||||
---
|
||||
|
||||
## P1 — Wire-format / streaming corruption
|
||||
|
||||
### P1-8. SSE buffers decoded with `errors="ignore"` / `errors="replace"`
|
||||
- **File:** `headroom/proxy/handlers/streaming.py:58, 772`; `headroom/ccr/response_handler.py:672`
|
||||
- **Evidence:** `chunk.decode("utf-8", errors="ignore")` silently drops emoji/CJK bytes split across TCP reads. The wire passthrough at `streaming.py:788` is bytes (correct), but every `_parse_sse_usage_from_buffer` and `_parse_sse_to_response` decision is made on a string that may have lost bytes.
|
||||
- **Guide:** §1.4 (UTF-8 multi-byte split across chunks).
|
||||
- **Fix:** Bytes-level buffer; find `\n\n` boundary in bytes; decode each complete event after split.
|
||||
- **Phase C → PR-C1 (Rust SSE parser); Phase A → PR-A8 includes a Python hotfix.**
|
||||
|
||||
### P1-9. SSE parser misses `thinking_delta`, `signature_delta`, `citations_delta`
|
||||
- **File:** `headroom/proxy/handlers/streaming.py:213-298`
|
||||
- **Evidence:** Only `text_delta` and `input_json_delta` are switched on (lines 268-271). Thinking blocks reconstructed without text or signature; signature-protected blocks rejected on replay.
|
||||
- **Guide:** §2.5, §2.7, §5.1 transitions table.
|
||||
- **Fix:** Add all delta-type arms. In Rust SSE parser (Phase C), implement guide §5.1 fully.
|
||||
- **Phase A → PR-A8 (Python); Phase C → PR-C1 (Rust).**
|
||||
|
||||
### P1-10. Memory continuation re-emitter emits whole `partial_json` in one delta
|
||||
- **File:** `headroom/proxy/handlers/streaming.py:300-391` (`_response_to_sse`)
|
||||
- **Evidence:** `"partial_json": json.dumps(block["input"])` (line 366-374) emits the entire input JSON as a single delta — clients accumulating per-spec receive one giant fragment instead of an incremental stream. Tool IDs are fabricated as `f"toolu_{idx}"` (line 345). Thinking blocks dropped entirely.
|
||||
- **Guide:** §2.6.
|
||||
- **Fix:** Either delete this function (do memory continuation as non-streaming retry) or rewrite to spec.
|
||||
- **Phase B → PR-B6 (memory injection refactor likely deletes it).**
|
||||
|
||||
### P1-11. LiteLLM bridge fabricates `toolu_<uuid>` when upstream `tc.id` missing
|
||||
- **File:** `headroom/backends/litellm.py:860`
|
||||
- **Evidence:** `tool_id = tc.id or f"toolu_{uuid.uuid4().hex[:24]}"`. If upstream omits `id` on chunk 1, fake ID is generated and the upstream tool_call_id is lost forever; next turn `tool_result` references the fake ID and pairing breaks.
|
||||
- **Guide:** §3.5, §2.10.
|
||||
- **Fix:** Drop the fallback; surface an error if `tc.id` is None on first appearance.
|
||||
- **Phase D → PR-D1 deletes this whole file.**
|
||||
|
||||
### P1-12. OpenAI WS→HTTP fallback uses single-`\n` SSE split
|
||||
- **File:** `headroom/proxy/handlers/openai.py:2422-2447`
|
||||
- **Evidence:** `aiter_text()` decodes UTF-8 chunk-by-chunk → `buffer.split("\n", 1)` instead of `\n\n`. Multi-line `data:` payloads get wrong-split.
|
||||
- **Fix:** Switch to `aiter_bytes()` + bytes-level `\n\n` boundary.
|
||||
- **Phase C → PR-C3 ports this surface to Rust.**
|
||||
|
||||
### P1-13. Re-serialization in Rust path even when no body fields mutated
|
||||
- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:90-188`
|
||||
- **Evidence:** When `should_apply` is true and ICM doesn't drop anything (`anthropic.rs:162-168`), the function correctly returns `NoCompression` and forwards original bytes. But the `Compressed` path always re-serializes via `serde_json::to_vec(&parsed)` — even if only one message changed, every retained message gets re-encoded through `Value`.
|
||||
- **Guide:** §1.12.
|
||||
- **Fix:** Use `RawValue` for retained `messages[*]` entries; only the modified message gets re-encoded.
|
||||
- **Phase A → PR-A4 / Phase B → PR-B2 (live-zone replacement).**
|
||||
|
||||
### P1-14. Mid-stream `error` events not handled (Anthropic + OpenAI)
|
||||
- **File:** `headroom/proxy/handlers/streaming.py:160-211, 213-298`
|
||||
- **Evidence:** No `event_type == "error"` arm. The wire passthrough is byte-faithful (good) but Headroom's bookkeeping (`stream_state.input_tokens` etc.) silently doesn't reflect the failure; `_finalize_stream_response` reports a clean PERF line for an errored stream.
|
||||
- **Guide:** §1.7, §2.21.
|
||||
- **Fix:** Add `error` handling to telemetry.
|
||||
- **Phase C → PR-C1 (Rust SSE).**
|
||||
|
||||
### P1-15. Connection drop without `message_stop`/`[DONE]` not surfaced
|
||||
- **File:** `headroom/proxy/handlers/streaming.py:899` (`finally: await self._finalize_stream_response`)
|
||||
- **Evidence:** The `finally` runs but there's no flag indicating the stream was truncated; logs report a PERF line as if it succeeded.
|
||||
- **Guide:** §1.8.
|
||||
- **Fix:** Track terminator-seen flag; emit truncation telemetry when missing.
|
||||
- **Phase C → PR-C1.**
|
||||
|
||||
### P1-16. OpenAI `refusal` field on Chat assistant message not handled
|
||||
- **File:** None (zero references)
|
||||
- **Evidence:** Memory and tool-call extraction look only at `message.content` / `tool_calls`; refusal turns silently look like content==null with output_tokens=0.
|
||||
- **Guide:** §3.7.
|
||||
- **Fix:** Inspect `refusal` field; surface in telemetry.
|
||||
- **Phase C → PR-C2 (Rust /v1/chat/completions).**
|
||||
|
||||
### P1-17. `current_block: Optional[dict]` instead of `blocks: HashMap<usize, BlockState>`
|
||||
- **File:** `headroom/proxy/handlers/streaming.py:227, 700`
|
||||
- **Evidence:** Anthropic emits one block at a time today, but the guide explicitly says "track blocks by `index`" — current code captures `index` and never uses it as a key.
|
||||
- **Guide:** §2.4, §5.1.
|
||||
- **Fix:** Index-keyed map.
|
||||
- **Phase C → PR-C1.**
|
||||
|
||||
---
|
||||
|
||||
## P2 — Architectural over-build
|
||||
|
||||
### P2-18. ICM-as-history-dropper (the structural mismatch)
|
||||
- **Files:** `headroom/transforms/intelligent_context.py`; `crates/headroom-core/src/context/manager.rs`; `crates/headroom-proxy/src/compression/icm.rs`
|
||||
- **Status:** Delete in Phase B (PR-B1).
|
||||
|
||||
### P2-19. `RollingWindow`, `ProgressiveSummarizer` (head-truncation strategies)
|
||||
- **Files:** `headroom/transforms/rolling_window.py` (395 LOC); `headroom/transforms/progressive_summarizer.py` (508 LOC)
|
||||
- **Guide:** §6.3 #11, §6.4 (compaction is the explicit exception, intended to break cache once).
|
||||
- **Status:** Delete in Phase B (PR-B1).
|
||||
|
||||
### P2-20. `MessageScorer`, `scoring/`, `relevance/` machinery
|
||||
- **Files:** `crates/headroom-core/src/scoring/{scorer,score,weights,traits,mod}.rs` (~1500 LOC); `crates/headroom-core/src/relevance/{embedding,bm25,hybrid,base,mod}.rs` (~1600 LOC); `headroom/transforms/scoring.py` (459 LOC)
|
||||
- **Evidence:** Sole consumer is `DropByScoreStrategy::try_fit`. Without ICM, no consumer.
|
||||
- **Status:** Delete in Phase B (PR-B1). MessageScorer Rust port (PR #338, #343) becomes wasted work.
|
||||
|
||||
### P2-21. `crates/headroom-core/src/context/` — except `safety.rs`
|
||||
- **Files:** `crates/headroom-core/src/context/{config,workspace,candidate,ccr_drop,manager,strategy/}.rs` (~1500 LOC)
|
||||
- **Status:** Delete in Phase B (PR-B1). `safety.rs` (tool-pair atomicity) is moved to `crates/headroom-core/src/transforms/safety.rs` and kept.
|
||||
|
||||
### P2-22. `ToolCrusher` operates without `frozen_message_count`
|
||||
- **File:** `headroom/transforms/tool_crusher.py:106`
|
||||
- **Evidence:** Iterates all result_messages, no frozen check. Crushes any tool message above token threshold regardless of position.
|
||||
- **Guide:** §10.1 (old tool results are cache-hot).
|
||||
- **Status:** Delete in Phase B (PR-B1). ContentRouter covers the use case correctly.
|
||||
|
||||
### P2-23. `CacheAligner` rewrite path violates the very thing it claims to stabilize
|
||||
- **File:** `headroom/transforms/cache_aligner.py:160-262`
|
||||
- **Evidence:** Strips dynamic content from system prompt and re-inserts as a context block — mutates the cache hot zone. Currently `enabled=False` in `server.py:299`.
|
||||
- **Guide:** §9.3.
|
||||
- **Fix:** Delete the rewrite path (~400 LOC); keep detector + customer warning (~140 LOC).
|
||||
- **Phase A → PR-A2 includes the deletion.**
|
||||
|
||||
### P2-24. Memory-handler injection at request lifecycle entry
|
||||
- **File:** `headroom/proxy/memory_handler.py:498-510`; `headroom/proxy/handlers/openai.py:535-540`
|
||||
- **Evidence:** Prepends a system message with retrieved memories on every turn. Retrieval is non-deterministic (vector store grows turn-to-turn).
|
||||
- **Fix:** Move retrieval out of the request lifecycle; treat as an explicit customer-invoked tool.
|
||||
- **Phase B → PR-B6 (memory refactor).**
|
||||
|
||||
### P2-25. CCR `ccr_retrieve` tool injected only when content was compressed
|
||||
- **File:** `headroom/ccr/tool_injection.py:302-328`
|
||||
- **Evidence:** `inject_tool_definition()` only adds the tool when `has_compressed_content` is true. Tool list size flips between requests.
|
||||
- **Guide:** §6.3 #2 (tool list reordering).
|
||||
- **Fix:** Inject `ccr_retrieve` on **every** request once a session has ever done CCR; or always inject for sessions that have CCR enabled.
|
||||
- **Phase B → PR-B7.**
|
||||
|
||||
### P2-26. CCR markers computed but never injected into outgoing body in Rust path
|
||||
- **File:** `crates/headroom-core/src/context/manager.rs:172-185`; `crates/headroom-proxy/src/proxy.rs:285`
|
||||
- **Evidence:** `markers_inserted` is logged but never written into the body. The model is never told about dropped messages or about `ccr_retrieve`.
|
||||
- **Guide:** §7.3 (reversibility).
|
||||
- **Fix:** Once Phase B replaces ICM, CCR-on-live-zone-content writes the marker into the block content as a side-channel. Phase B PR-B7.
|
||||
|
||||
### P2-27. TOIN influences per-request decisions
|
||||
- **File:** `headroom/telemetry/toin.py:853-927`
|
||||
- **Evidence:** `get_recommendation()` consults pattern stats and returns hints that bias compression decisions; `pattern.observations += 1` mutates state during the call.
|
||||
- **Guide:** §7.1, §11.17, §11.18.
|
||||
- **Fix:** Strict observation-only. Recommendations published at deploy time, never altered request-time.
|
||||
- **Phase B → PR-B5.**
|
||||
|
||||
---
|
||||
|
||||
## P3 — Missing infrastructure (Phase 3 cache stabilization)
|
||||
|
||||
### P3-28. No tool-array deterministic sort in Rust path
|
||||
- **File:** Missing entirely in `crates/headroom-proxy/`
|
||||
- **Evidence:** Python sorts at `handlers/anthropic.py:1198, 1217, 2041, 2118`; Rust does not.
|
||||
- **Guide:** §8.5, §9.11.
|
||||
- **Phase E → PR-E1.**
|
||||
|
||||
### P3-29. JSON Schema keys never sorted recursively
|
||||
- **File:** None — `_sort_tools_deterministically` only sorts the tools array, not their `input_schema` contents.
|
||||
- **Guide:** §8.5.
|
||||
- **Phase E → PR-E2.**
|
||||
|
||||
### P3-30. No `prompt_cache_key` auto-injection
|
||||
- **Evidence:** Zero references in the codebase.
|
||||
- **Guide:** §4.17.
|
||||
- **Phase E → PR-E4.**
|
||||
|
||||
### P3-31. No `cache_control` auto-placement (Anthropic)
|
||||
- **Evidence:** `cache_control` only mentioned in stripping for hashing (`helpers.py:295-304`) and pass-through (`server.py:1053`).
|
||||
- **Guide:** §2.19, §6.2.
|
||||
- **Phase E → PR-E3.**
|
||||
|
||||
### P3-32. No volatile-content detector + warning
|
||||
- **Evidence:** `cache_aligner` has detection but rewrites instead of warning.
|
||||
- **Guide:** §9.3.
|
||||
- **Phase E → PR-E5.**
|
||||
|
||||
### P3-33. No per-block token validation with fallback
|
||||
- **Evidence:** Compression acceptance is `bytes_saved > 0` (`crates/headroom-core/src/transforms/pipeline/orchestrator.rs:158-165`); ICM aggregate-checks tokens (`anthropic.rs:162-168`) but per-block transforms don't.
|
||||
- **Guide:** §7.5, §11.15, §11.20.
|
||||
- **Phase B → PR-B4.**
|
||||
|
||||
### P3-34. No per-content-type byte thresholds
|
||||
- **Evidence:** Threshold gating is by ratio (`bloat_threshold=0.5`) not by bytes (code>2KB, JSON>1KB, logs>500B, plain text>5KB per guide §7.6).
|
||||
- **Phase B → PR-B4.**
|
||||
|
||||
### P3-35. No cache-bust drift detector telemetry
|
||||
- **Evidence:** No prefix-hash drift detection across requests.
|
||||
- **Phase E → PR-E6.**
|
||||
|
||||
### P3-36. No shared content-hash cache across customers (Phase 4)
|
||||
- **Evidence:** `CompressionCache` is per-session, per-worker.
|
||||
- **Status:** Out of scope for this realignment; queued for Phase 4 of the guide.
|
||||
|
||||
---
|
||||
|
||||
## P4 — OpenAI long-tail + Bedrock/Vertex
|
||||
|
||||
### P4-37. **Bedrock support is fake — lossy LiteLLM converter**
|
||||
- **File:** `headroom/backends/litellm.py:486-628`
|
||||
- **Evidence:** `_convert_messages_for_litellm` switch covers only `text` / `tool_use` / `tool_result`; drops `thinking`, `redacted_thinking`, `document`, `search_result`, `image`, `server_tool_use`, `mcp_tool_use`. Response converter hardcodes `"stop_sequence": None` (line 626) — §11.1 violation. Function-call arguments parsed and rewrapped (line 600) — string fidelity broken (§4.4).
|
||||
- **Phase D → PR-D1, D2, D3 rebuild natively.**
|
||||
|
||||
### P4-38. Vertex same lossy converter
|
||||
- **File:** Same — `headroom/backends/litellm.py`
|
||||
- **Phase D → PR-D4 builds native Vertex.**
|
||||
|
||||
### P4-39. No native Bedrock/Vertex paths in Rust
|
||||
- **File:** `crates/headroom-proxy/src/compression/mod.rs:50` only matches `/v1/messages`.
|
||||
- **Phase D → PR-D1-D4.**
|
||||
|
||||
### P4-40. `/v1/conversations` blind spot (§4.14)
|
||||
- **Evidence:** Zero references. Server-side prepended items invisible to Headroom; tokenizer count over-reports.
|
||||
- **Phase C → PR-C4.**
|
||||
|
||||
### P4-41. `service_tier` never logged or surfaced
|
||||
- **Guide:** §4.12.
|
||||
- **Phase G → PR-G3 (observability).**
|
||||
|
||||
### P4-42. `incomplete`, `failed`, `cancelled` statuses never surfaced
|
||||
- **Guide:** §4.10.
|
||||
- **Phase C → PR-C3 / C4.**
|
||||
|
||||
### P4-43. `function_call.arguments` parsed-and-rewrapped in 2 places
|
||||
- **File:** `headroom/backends/litellm.py:600`; `headroom/learn/plugins/codex.py:283`
|
||||
- **Guide:** §4.4.
|
||||
- **Phase D → PR-D1 deletes litellm.py; learn plugin moved to read-only.**
|
||||
|
||||
### P4-44. `phase` field "accidentally preserved" via `copy.copy(original)`
|
||||
- **File:** `headroom/proxy/responses_converter.py:94, 235`
|
||||
- **Status:** Already covered by P0-7. **Phase A → PR-A8** (hotfix), **Phase C → PR-C5** (full rebuild).
|
||||
|
||||
### P4-45. `image_generation_call` no log redaction
|
||||
- **File:** `headroom/proxy/request_logger.py` — no base64/image redaction
|
||||
- **Guide:** §11.6.
|
||||
- **Phase G → PR-G3 includes a redaction step.**
|
||||
|
||||
### P4-46. `Cargo.toml` missing `arbitrary_precision` + `raw_value` features on `serde_json`
|
||||
- **File:** `Cargo.toml:34`
|
||||
- **Phase A → PR-A4 enables them.**
|
||||
|
||||
### P4-47. Apply patch V4A, local_shell_call argv, MCP items, compaction items only "accidentally" preserved
|
||||
- **File:** `headroom/proxy/responses_converter.py:99` — "Unknown item type: preserve"
|
||||
- **Evidence:** Survives only because the catch-all is conservative. No log line, no test. One refactor away from silent data loss.
|
||||
- **Phase A → PR-A8 adds a warning log; Phase C → PR-C5 makes it explicit.**
|
||||
|
||||
### P4-48. No SSE parser in Rust at all
|
||||
- **Status:** Phase 1 of the Rust proxy was passthrough; Phase C builds the parser.
|
||||
- **Phase C → PR-C1.**
|
||||
|
||||
---
|
||||
|
||||
## P5 — Auth-mode + observability + fingerprinting
|
||||
|
||||
### P5-49. `X-Headroom-*` request headers leak upstream
|
||||
- **File:** `headroom/proxy/handlers/anthropic.py:526` — `dict(request.headers.items())` captured unmodified, no strip step before `httpx.post(headers=headers)`.
|
||||
- **Risk:** Subscription-revocation fingerprint.
|
||||
- **Phase A → PR-A5.**
|
||||
|
||||
### P5-50. `anthropic-beta` mutated when memory enabled, not session-sticky
|
||||
- **File:** `headroom/proxy/handlers/anthropic.py:1162-1168`
|
||||
- **Status:** Already covered by P0-6. **Phase A → PR-A6, A7.**
|
||||
|
||||
### P5-51. `OpenAI-Beta` auto-injection on WS path
|
||||
- **File:** `headroom/proxy/handlers/openai.py:1566-1567`
|
||||
- **Risk:** OAuth scope rejection if scope doesn't grant the auto-injected beta.
|
||||
- **Phase F → PR-F2 (gate by mode).**
|
||||
|
||||
### P5-52. `accept-encoding` stripped — fingerprint signal
|
||||
- **File:** `handlers/anthropic.py:533`, `handlers/openai.py:264`
|
||||
- **Risk:** Real Claude Code negotiates compression; stripping reveals the proxy.
|
||||
- **Phase F → PR-F2 (preserve when subscription mode).**
|
||||
|
||||
### P5-53. `X-Forwarded-*` always added by Rust proxy
|
||||
- **File:** `crates/headroom-proxy/src/headers.rs:103-117`
|
||||
- **Phase F → PR-F4 (conditional on auth mode).**
|
||||
|
||||
### P5-54. Subscription tracker stores raw OAuth bearer token in process memory
|
||||
- **File:** `headroom/subscription/tracker.py:166`
|
||||
- **Risk:** Core dump or debugger attach exposes the token.
|
||||
- **Phase F → PR-F3 hardens (hash + only the ID, not the token).**
|
||||
|
||||
### P5-55. Auth-mode never drives compression policy
|
||||
- **Evidence:** Single policy applied to all three modes today.
|
||||
- **Phase F → PR-F1 (`classify_auth_mode`), PR-F2 (gates).**
|
||||
|
||||
### P5-56. TOIN aggregates globally by `structure_hash` only
|
||||
- **File:** `headroom/telemetry/toin.py:477, 496`
|
||||
- **Risk:** Cross-tenant pattern leakage.
|
||||
- **Phase F → PR-F3 changes key to `(auth_mode, model_family, structure_hash)`.**
|
||||
|
||||
### P5-57. Upstream `request-id` not captured in logs
|
||||
- **File:** `crates/headroom-proxy/src/proxy.rs:355-358, 377-383`
|
||||
- **Guide:** §11.10.
|
||||
- **Phase A → PR-A8 (telemetry capture in Python); Phase C carries forward to Rust.**
|
||||
|
||||
### P5-58. Rate-limit headers forwarded but never observed
|
||||
- **File:** `crates/headroom-proxy/src/headers.rs:126-139`
|
||||
- **Guide:** §11.9.
|
||||
- **Phase G → PR-G3 (Prometheus metric).**
|
||||
|
||||
### P5-59. Body size cap returns wrong status code (400 instead of 413)
|
||||
- **File:** `crates/headroom-proxy/src/proxy.rs:243-263`
|
||||
- **Phase A → PR-A8 fix, low priority.**
|
||||
|
||||
### P5-60. `tokens_saved_rtk` field is dead (allocated, never populated)
|
||||
- **File:** `headroom/subscription/models.py:260`; `headroom/subscription/tracker.py:173`
|
||||
- **Phase G → PR-G2.**
|
||||
|
||||
### P5-61. RTK never invoked from proxy (correct posture; document explicitly)
|
||||
- **Status:** Per audit recommendation (Agent F): proxy-side invocation is wrong; cache hot zone risk + parallel impl with `log_compressor.rs`. Document explicitly so future contributors don't add it.
|
||||
- **Phase G → PR-G1, G3.**
|
||||
|
||||
### P5-62. Wrap CLIs missing for cline, continue, goose, openhands, devin-style CLIs
|
||||
- **Files:** `headroom/cli/wrap.py` — only Claude/Codex/Aider/Copilot/Cursor today.
|
||||
- **Phase G → PR-G1.**
|
||||
|
||||
---
|
||||
|
||||
## P6 — Test-infra & parity
|
||||
|
||||
### P6-63. No SHA-256 byte-faithful round-trip test on recorded production payload
|
||||
- **Phase A → PR-A8.**
|
||||
|
||||
### P6-64. `ccr`, `log_compressor`, `cache_aligner` parity comparators are `Skipped` stubs
|
||||
- **File:** `crates/headroom-parity/src/lib.rs:172-174`
|
||||
- **Phase I (parallel) — promote stubs to real comparators.**
|
||||
|
||||
### P6-65. `make test-parity` not a per-PR gate
|
||||
- **File:** `.github/workflows/rust.yml:125-149` — nightly only, `continue-on-error: true`
|
||||
- **Phase I — make per-PR; `Diff` fails build, `Skipped` allowed.**
|
||||
|
||||
### P6-66. No SSE corner-case fixtures (UTF-8 split, ping, all delta types, [DONE], mid-stream error)
|
||||
- **Phase I — record fixtures during Phase C work.**
|
||||
|
||||
### P6-67. No real-traffic shadow test comparing Python vs Rust output byte-for-byte
|
||||
- **Phase I — implement during Phase C.**
|
||||
|
||||
### P6-68. No per-session cache-hit-rate metric
|
||||
- **File:** `headroom/proxy/prometheus_metrics.py` — only aggregate by provider
|
||||
- **Phase G → PR-G3.**
|
||||
|
||||
### P6-69. No per-block compression-ratio histogram (only invocation count)
|
||||
- **Phase G → PR-G3.**
|
||||
|
||||
### P6-70. No token-validation rejection counter
|
||||
- **Phase B → PR-B4 emits the metric.**
|
||||
|
||||
### P6-71. WS-handshake `OpenAI-Beta` injection un-tested for OAuth-scope rejection paths
|
||||
- **Phase I — record a fixture.**
|
||||
|
||||
### P6-72. Wrap E2E uses an `rtk` shim that just exits 0 (`e2e/wrap/run.py:250-267`) — doesn't exercise real RTK
|
||||
- **Phase I — replace shim with a containerized real RTK or assert-on-shim-only-in-CI flag.**
|
||||
|
||||
---
|
||||
|
||||
## Summary table
|
||||
|
||||
| Priority | Count | Location |
|
||||
|---|---:|---|
|
||||
| P0 (cache-killer) | 7 | Phase A |
|
||||
| P1 (wire-format) | 10 | Phase A + Phase C |
|
||||
| P2 (over-build) | 10 | Phase B |
|
||||
| P3 (missing Phase 3) | 9 | Phase E |
|
||||
| P4 (long-tail + Bedrock) | 12 | Phase C + Phase D |
|
||||
| P5 (auth + obs + fingerprint) | 14 | Phase F + Phase G |
|
||||
| P6 (test infra) | 10 | Phase I (parallel) |
|
||||
| **Total** | **72** | — |
|
||||
@@ -0,0 +1,322 @@
|
||||
# 02 — Realigned Target Architecture
|
||||
|
||||
The Rust-only proxy after Phase H. Each subsystem documented with its scope, invariants, file layout, and what it explicitly does NOT do.
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Request lifecycle (Rust, post-Phase-C)
|
||||
|
||||
```
|
||||
Client request
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ headroom-proxy (axum) │
|
||||
│ │
|
||||
│ 1. classify_auth_mode(headers) │ ← Phase F
|
||||
│ → "payg" | "oauth" | "subscription" │
|
||||
│ │
|
||||
│ 2. strip x-headroom-* from upstream-bound │ ← Phase A (PR-A5)
|
||||
│ │
|
||||
│ 3. byte-buffer body via RawValue │ ← Phase A (PR-A4)
|
||||
│ (numeric precision preserved) │
|
||||
│ │
|
||||
│ 4. honor cache_control markers │ ← Phase A (PR-A4)
|
||||
│ → frozen_message_count │
|
||||
│ │
|
||||
│ 5. live_zone_compress(body, frozen_count, │ ← Phase B
|
||||
│ auth_mode) │
|
||||
│ ├─ identify live-zone blocks │
|
||||
│ ├─ per-block content-type detection │
|
||||
│ ├─ dispatch to type-aware compressor │
|
||||
│ ├─ token-validate; fallback to original │
|
||||
│ ├─ CCR: hash-key, store, marker │
|
||||
│ └─ replace block bytes in-place │
|
||||
│ │
|
||||
│ 6. tool_def_normalize(body) │ ← Phase E (PR-E1, E2)
|
||||
│ ├─ alpha-sort tools[] │
|
||||
│ └─ recursive-sort JSON Schema keys │
|
||||
│ │
|
||||
│ 7. cache_control_auto_place(body) │ ← Phase E (PR-E3)
|
||||
│ (Anthropic; up to 4 ephemeral) │
|
||||
│ │
|
||||
│ 8. prompt_cache_key_inject(body) │ ← Phase E (PR-E4)
|
||||
│ (OpenAI; only if not customer-set) │
|
||||
│ │
|
||||
│ 9. forward via reqwest with original bytes │
|
||||
│ for unmodified envelope (RawValue diff) │
|
||||
│ │
|
||||
│ 10. SSE response: byte-level state machine │ ← Phase C (PR-C1)
|
||||
│ ├─ track blocks/items by id │
|
||||
│ ├─ all delta types handled │
|
||||
│ ├─ mid-stream error/ping/drop surfaced │
|
||||
│ └─ pure passthrough to client │
|
||||
│ │
|
||||
│ 11. usage telemetry (cache_read, │ ← Phase G
|
||||
│ cache_creation, output_tokens, etc.) │
|
||||
└──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Upstream provider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.2 The cache-safety invariants (every PR enforces)
|
||||
|
||||
### Invariant I1 — Byte-faithful passthrough on unmutated bytes
|
||||
For every request, the bytes sent to upstream are byte-equal (SHA-256) to the bytes received from the client, **modulo only the byte ranges that a transform explicitly modified**. No re-serialization through a `Value` type. No JSON-prettifier whitespace insertion. No `\uXXXX` ASCII escaping of UTF-8 user content.
|
||||
|
||||
**Implementation:** `serde_json::value::RawValue` for `messages[*]` entries; modified messages get fresh serialization, retained messages forward as exact byte copies. Workspace `Cargo.toml` adds `arbitrary_precision` + `raw_value` features.
|
||||
|
||||
**Test gate:** `proxy_byte_faithful_anthropic_sha256` — record a real Anthropic `/v1/messages` payload, send it through the proxy with compression off, assert SHA-256 byte-equal at the upstream mock.
|
||||
|
||||
### Invariant I2 — Cache hot zone never modified
|
||||
The following are never mutated by Headroom:
|
||||
- `system` (string or block list)
|
||||
- `tools[*]` (other than alpha-sorting and JSON Schema key sorting in Phase E — both deterministic)
|
||||
- Any message at index < `frozen_message_count`
|
||||
- Reasoning items with `encrypted_content`
|
||||
- Thinking blocks with `signature`
|
||||
- `redacted_thinking.data`
|
||||
- Compaction items (`{"type": "compaction", "encrypted_content": ...}`)
|
||||
|
||||
**Implementation:** `live_zone_compress` walks `messages` from the tail, identifies live-zone blocks (latest user message, latest tool_result, latest function_call_output, latest local_shell_call_output, latest apply_patch_call_output), and ONLY modifies bytes within those blocks.
|
||||
|
||||
**Test gate:** `cache_hot_zone_unchanged_under_compression` — fixture with system + tools + 5 historical turns + new tool_result; assert system + tools + first 5 turns bytes equal at upstream.
|
||||
|
||||
### Invariant I3 — Append-only
|
||||
Once a message has appeared in any prior request to upstream, its bytes are frozen. Compression operates on the live zone (latest turn) only.
|
||||
|
||||
**Implementation:** `frozen_message_count` is the floor; any compressor that touches index < `frozen_message_count` is rejected at compile time (Rust trait constraint) or runtime (Python assertion).
|
||||
|
||||
**Test gate:** `append_only_invariant_under_recompression` — same input bytes through the compressor twice produces byte-equal output; retained messages are byte-equal across the two runs.
|
||||
|
||||
### Invariant I4 — Determinism
|
||||
For the same `(input bytes, frozen_count, auth_mode)`, the compressor produces byte-equal output. No timestamps, no random seeds, no time-dependent decisions.
|
||||
|
||||
**Implementation:**
|
||||
- TOIN is observation-only (Phase B PR-B5); it never alters request-time decisions.
|
||||
- All hashing is BLAKE3 / SHA-256 with stable input ordering.
|
||||
- Sort orders are explicit (`BTreeMap` for output, never `HashMap`).
|
||||
- No `Instant::now()` in any compression code path.
|
||||
|
||||
**Test gate:** Property test — for arbitrary valid input, `compress(input) == compress(compress(input).original)` (idempotence on already-compressed); `compress(input) == compress(input)` (run-to-run determinism).
|
||||
|
||||
### Invariant I5 — Token-aware, not byte-aware
|
||||
Every compression is validated post-compression with a tokenizer. If `compressed.tokens >= original.tokens`, the original is forwarded.
|
||||
|
||||
**Implementation:** Phase B PR-B4. Per-content-type byte thresholds: code>2KB, JSON>1KB, logs>500B, plain text>5KB. Below threshold = no compression attempted (overhead exceeds savings).
|
||||
|
||||
**Test gate:** `proptest_compression_token_count_non_increasing` — for arbitrary valid inputs from a strategy, `tokens(output) ≤ tokens(input)`.
|
||||
|
||||
### Invariant I6 — Position-preserving
|
||||
Compression never reorders blocks within a content array, never splits one block into multiple, never adds inline metadata fields to existing blocks.
|
||||
|
||||
**Implementation:** Compressor signature is `fn(block: &mut Block) -> Result<()>` — operates in place. Block type, `tool_use_id` / `call_id`, `is_error`, all sibling fields preserved.
|
||||
|
||||
**Side-channel metadata:** A separate marker block (text-type, sibling) carries CCR retrieval directives. Never an extra field on the original block.
|
||||
|
||||
### Invariant I7 — Tool definitions normalized, not compressed
|
||||
Tools are sorted alphabetically by name; JSON Schema keys are sorted recursively; description whitespace is normalized. The bytes of each tool definition's `input_schema.properties[*].description` are otherwise preserved.
|
||||
|
||||
**Implementation:** Phase E PR-E1, PR-E2.
|
||||
|
||||
### Invariant I8 — `signature`, `encrypted_content`, `redacted_thinking.data` are sacrosanct
|
||||
These are passthrough only. Never inspected, never decoded, never transformed.
|
||||
|
||||
**Implementation:** Compressor block-type dispatch has explicit no-op arms for these types. The Bedrock/Vertex native paths (Phase D) preserve them unlike the LiteLLM converter.
|
||||
|
||||
### Invariant I9 — TOIN observes, never mutates request bytes
|
||||
TOIN's pattern stats grow across requests. Recommendations are published to disk between deploys. The compressor reads recommendations at startup, not per-request.
|
||||
|
||||
**Implementation:** Phase B PR-B5. TOIN's in-memory state writes are append-only; reads never block compression.
|
||||
|
||||
### Invariant I10 — Auth mode gates compression policy
|
||||
PAYG: aggressive (full live-zone compression, CCR, tool injection, Phase 3 stabilization). OAuth: passthrough-prefer (live-zone lossless only, no auto-`cache_control`, no auto-`prompt_cache_key`, no `X-Forwarded-*`). Subscription: stealth-prefer (everything OAuth does PLUS preserve `accept-encoding`, never inject `X-Headroom-*` upstream, never mutate `User-Agent`).
|
||||
|
||||
**Implementation:** Phase F PR-F1, PR-F2.
|
||||
|
||||
---
|
||||
|
||||
## 2.3 The compressor module layout (post-Phase-B)
|
||||
|
||||
```
|
||||
crates/headroom-core/src/
|
||||
├── lib.rs # public surface
|
||||
├── tokenizer/ # KEEP (HF + tiktoken impls)
|
||||
│ ├── mod.rs
|
||||
│ ├── hf_impl.rs
|
||||
│ ├── tiktoken_impl.rs
|
||||
│ ├── estimator.rs
|
||||
│ └── registry.rs
|
||||
├── ccr.rs # KEEP, hardened (persistent backend)
|
||||
├── signals/ # KEEP — drives live-zone consumers
|
||||
│ ├── mod.rs
|
||||
│ ├── line_importance.rs
|
||||
│ ├── keyword_detector.rs
|
||||
│ └── tiered.rs
|
||||
├── transforms/ # the compressors
|
||||
│ ├── mod.rs
|
||||
│ ├── safety.rs # MOVED from context/safety.rs (Phase B)
|
||||
│ ├── live_zone.rs # NEW — live-zone block dispatcher (Phase B)
|
||||
│ ├── content_detector.rs # KEEP
|
||||
│ ├── detection.rs # KEEP
|
||||
│ ├── magika_detector.rs # KEEP
|
||||
│ ├── unidiff_detector.rs # KEEP
|
||||
│ ├── adaptive_sizer.rs # KEEP
|
||||
│ ├── anchor_selector.rs # KEEP
|
||||
│ ├── tag_protector.rs # KEEP
|
||||
│ ├── log_compressor.rs # KEEP
|
||||
│ ├── search_compressor.rs # KEEP
|
||||
│ ├── diff_compressor.rs # KEEP
|
||||
│ ├── kompress_compressor.rs # NEW — Phase H Rust port via `ort` crate
|
||||
│ ├── smart_crusher/ # KEEP (25 files, correctly scoped)
|
||||
│ └── pipeline/ # SHRUNK — only the live-zone orchestrator
|
||||
│ ├── mod.rs
|
||||
│ ├── orchestrator.rs # rewrite to live-zone-only
|
||||
│ ├── traits.rs # LosslessTransform / LossyTransform
|
||||
│ └── offloads/ # KEEP — JSON, log, search, diff offloads
|
||||
└── auth_mode.rs # NEW — Phase F (classify_auth_mode helper)
|
||||
|
||||
# DELETED in Phase B:
|
||||
# context/ ← except safety.rs which moved
|
||||
# scoring/
|
||||
# relevance/
|
||||
```
|
||||
|
||||
```
|
||||
crates/headroom-proxy/src/
|
||||
├── lib.rs
|
||||
├── main.rs
|
||||
├── config.rs
|
||||
├── error.rs
|
||||
├── proxy.rs # Phase A: pure passthrough on /v1/messages
|
||||
# Phase C: + /v1/chat/completions, /v1/responses
|
||||
├── headers.rs # Phase F: conditional X-Forwarded-*
|
||||
├── websocket.rs # Phase C: WS Codex flow
|
||||
├── sse/ # NEW — Phase C
|
||||
│ ├── mod.rs
|
||||
│ ├── parser.rs # byte-level state machine
|
||||
│ ├── anthropic.rs # 4-event dance + delta types
|
||||
│ ├── openai_chat.rs # tool_call accumulation
|
||||
│ └── openai_responses.rs # output items + reasoning summary
|
||||
├── compression/
|
||||
│ ├── mod.rs # routing by path × auth_mode
|
||||
│ ├── live_zone_anthropic.rs # NEW (Phase B)
|
||||
│ ├── live_zone_openai.rs # NEW (Phase C)
|
||||
│ ├── tool_def_normalize.rs # NEW (Phase E)
|
||||
│ ├── cache_control.rs # NEW (Phase E)
|
||||
│ └── model_limits.rs # KEEP
|
||||
├── bedrock/ # NEW — Phase D
|
||||
│ ├── mod.rs
|
||||
│ ├── sigv4.rs
|
||||
│ ├── invoke.rs
|
||||
│ └── eventstream.rs
|
||||
├── vertex/ # NEW — Phase D
|
||||
│ ├── mod.rs
|
||||
│ ├── adc.rs
|
||||
│ └── stream_raw_predict.rs
|
||||
└── observability/ # NEW — Phase G
|
||||
├── mod.rs
|
||||
├── prometheus.rs
|
||||
├── cache_hit_rate.rs
|
||||
└── compression_ratio.rs
|
||||
|
||||
# DELETED:
|
||||
# compression/icm.rs ← Phase A PR-A1
|
||||
# compression/anthropic.rs ← Phase A PR-A1 (replaced with live_zone_anthropic.rs in Phase B)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 The auth-mode policy matrix (Phase F)
|
||||
|
||||
| Policy aspect | PAYG | OAuth | Subscription |
|
||||
|---|---|---|---|
|
||||
| Live-zone compression | aggressive | lossless-only | lossless-only |
|
||||
| CCR enabled | yes | yes | yes (long-session) |
|
||||
| Tool def alpha-sort | yes | yes | yes |
|
||||
| JSON Schema key sort | yes | yes | yes |
|
||||
| Auto `cache_control` placement | yes | NO (could void scope) | NO |
|
||||
| Auto `prompt_cache_key` injection | yes (OpenAI) | NO | NO |
|
||||
| `anthropic-beta` mutation | NO | NO | NO |
|
||||
| `X-Headroom-*` upstream | NO | NO | NO |
|
||||
| `X-Forwarded-*` upstream | yes | yes | NO |
|
||||
| `User-Agent` rewrite | NO | NO | NO |
|
||||
| `accept-encoding` strip | OK | OK | NO (preserve) |
|
||||
| Lossy compressors (LLMLingua) | OK | NO | NO |
|
||||
| Memory injection | live-zone tail | live-zone tail (gated) | live-zone tail (gated) |
|
||||
| TOIN aggregation key | (mode, model) | (mode, model) | (mode, model) |
|
||||
| `Authorization` log redaction | first 12 chars | first 12 chars | first 12 chars |
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Preserved primitives detail
|
||||
|
||||
### TOIN (post-Phase-B-PR-B5)
|
||||
|
||||
```rust
|
||||
// Strict observation-only.
|
||||
pub trait Telemetry {
|
||||
fn record_compression(
|
||||
&self,
|
||||
auth_mode: AuthMode,
|
||||
model: ModelFamily,
|
||||
structure_hash: StructureHash,
|
||||
outcome: CompressionOutcome,
|
||||
);
|
||||
// No request-time hint API. Period.
|
||||
}
|
||||
|
||||
// Recommendations published between deploys via:
|
||||
// $ cargo run -p headroom-toin-publish -- --auth-mode payg --model claude-3-7-sonnet
|
||||
// Output: recommendations.toml committed to repo, loaded by compressor at startup.
|
||||
```
|
||||
|
||||
### CCR (post-Phase-B-PR-B7)
|
||||
|
||||
```rust
|
||||
pub trait CcrStore: Send + Sync {
|
||||
fn put(&self, hash: ContentHash, original: Bytes, ttl: Duration) -> Result<()>;
|
||||
fn get(&self, hash: ContentHash) -> Result<Option<Bytes>>;
|
||||
fn purge_expired(&self) -> usize;
|
||||
}
|
||||
|
||||
pub struct SqliteCcrStore { ... } // primary backend
|
||||
pub struct RedisCcrStore { ... } // optional, for multi-worker
|
||||
|
||||
// `ccr_retrieve` tool registered on every request for sessions that ever did CCR.
|
||||
// Marker injection format: `<<ccr:HASH>>` appended to compressed block content.
|
||||
// Markers are deterministic (hash is content-addressed); replay-safe.
|
||||
```
|
||||
|
||||
### Kompress-base (post-Phase-H-PR-H4 Rust port)
|
||||
|
||||
```rust
|
||||
// Plain-text §8.6 compressor. Used only as a last resort, only on live-zone
|
||||
// user-message text exceeding 5KB.
|
||||
pub struct KompressCompressor {
|
||||
// ONNX runtime via `ort` crate. Model deterministic for fixed weights.
|
||||
session: ort::Session,
|
||||
threshold_bytes: usize,
|
||||
}
|
||||
|
||||
impl LossyTransform for KompressCompressor { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.6 What this architecture explicitly does NOT do
|
||||
|
||||
- Does NOT drop messages from history. Ever. ICM is gone.
|
||||
- Does NOT modify `system`, `tools`, or any old turn.
|
||||
- Does NOT inject Headroom's own tools into customer prompts unless CCR has already fired in this session (and then always, never toggling).
|
||||
- Does NOT consult TOIN at request time. Recommendations are loaded at startup only.
|
||||
- Does NOT shell out to RTK from the proxy. RTK lives on the wrap-CLI side (project-decided 2026-05-01).
|
||||
- Does NOT translate Anthropic ↔ OpenAI shapes. Each provider has its own native handler. Bedrock and Vertex have native envelopes (Phase D).
|
||||
- Does NOT compress on `/v1/responses/compact` or `/v1/conversations` (different shapes; passthrough only).
|
||||
- Does NOT rewrite request headers except to strip `x-headroom-*` from upstream-bound headers and add conditional `X-Forwarded-*` (PAYG/OAuth only).
|
||||
- Does NOT add `User-Agent` headers. The customer's UA passes through verbatim.
|
||||
- Does NOT compress images, base64 blobs, or audio (out of scope for this realignment).
|
||||
- Does NOT modify `tool_use.input` JSON key order, `tool_calls.function.arguments` string contents, `phase` field, V4A patches, `local_shell_call.action.command` argv arrays, or any encrypted/redacted/compaction content.
|
||||
@@ -0,0 +1,420 @@
|
||||
# Phase A — Cache-Safety Lockdown
|
||||
|
||||
**Goal:** Stop the cache-killer bleeding tonight. Each PR is small, low-risk, independently reversible. Zero new architecture; minimum viable fixes only.
|
||||
|
||||
**Calendar:** 1 week. PR-A1 lands today; A2–A8 over the week.
|
||||
|
||||
**Shape:** 8 PRs, each on its own branch, each in its own worktree. Sequential dependency only between A1→A4; the rest are parallelizable.
|
||||
|
||||
---
|
||||
|
||||
## PR-A1 — Make `/v1/messages` compression a passthrough
|
||||
|
||||
**Branch:** `realign-A1-icm-passthrough`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A1-icm-passthrough`
|
||||
**Risk:** **LOW** (deletion + tests; no new logic)
|
||||
**LOC:** -180 / +30
|
||||
|
||||
### Scope
|
||||
Stop calling ICM from the Rust proxy on `/v1/messages`. The proxy becomes a pure byte-faithful passthrough on this endpoint. Zero compression value temporarily, but eliminates the C1+C2+C3+C4 cache-killer cluster (P0-3, P0-4, P0-5, P1-13). Compression returns in Phase B.
|
||||
|
||||
### Files
|
||||
|
||||
**Delete:**
|
||||
- `crates/headroom-proxy/src/compression/icm.rs`
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/mod.rs` — remove `pub mod icm;`, remove ICM dispatch in `maybe_compress`. The `is_compressible_path` check still matches `/v1/messages` but `compress_anthropic_request` becomes a no-op stub returning `Outcome::NoCompression`.
|
||||
- `crates/headroom-proxy/src/compression/anthropic.rs` — replace function body with `Ok(Outcome::NoCompression)`. Keep the function signature so callers compile; subsequent PRs in Phase B replace this with the live-zone block dispatcher.
|
||||
- `crates/headroom-proxy/src/proxy.rs` — confirm the `Outcome::NoCompression` branch forwards original bytes (already does at line 296-298; just verify with the new test).
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_compression.rs::compression_on_message_passes_body_unchanged_sha256` — record a real Anthropic request body to a fixture; send through proxy; assert SHA-256 of upstream-received body equals SHA-256 of inbound body.
|
||||
|
||||
**Tests deleted/updated:**
|
||||
- Update `compression_on_short_body_passes_through` to assert SHA-256 byte-equality (not just `len()`).
|
||||
- Update `compression_on_long_body_drops_messages` — rename to `compression_on_long_body_passes_through_in_phase_A`. The old assertion (fewer messages arrived) becomes the opposite (same messages arrive).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo test -p headroom-proxy` green.
|
||||
- New SHA-256 round-trip test passes.
|
||||
- The proxy still starts and serves `/healthz`.
|
||||
- `make ci-precheck` green.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None. Land first.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-A4 (cache_control honoring needs the ICM call site removed first to avoid conflict).
|
||||
All Phase B PRs (which delete the surrounding code).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert` the merge commit. ICM is restored. (Note: this also restores P0-3 and P0-4. Acceptable for ~hours during emergency rollback.)
|
||||
|
||||
### Notes
|
||||
|
||||
- The `compress_anthropic_request` function stays as a stub so Phase B has a single rewrite target.
|
||||
- This PR does NOT delete ICM the module yet — `crates/headroom-core/src/context/manager.rs` still compiles. PR-B1 deletes the modules. Splitting keeps the diff scoped.
|
||||
|
||||
---
|
||||
|
||||
## PR-A2 — Stop mutating the system prompt; route memory context to live zone
|
||||
|
||||
**Branch:** `realign-A2-system-prompt-immutable`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A2-system-prompt-immutable`
|
||||
**Risk:** **MEDIUM** (touches memory feature behavior)
|
||||
**LOC:** -150 / +80
|
||||
|
||||
### Scope
|
||||
Eliminate P0-1 and P2-23. The system prompt is never mutated; memory context is appended to the latest user message tail (live zone). Delete the cache_aligner rewrite path; keep the volatile-content detector for warnings only.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/server.py:1026-1071` — delete `_inject_system_context`. Memory context handling routes exclusively through `_append_context_to_latest_non_frozen_user_turn` (already exists at `handlers/anthropic.py:1117-1135` for cache mode; promote to default).
|
||||
- `headroom/proxy/handlers/openai.py:1212` — same: delete `body["instructions"] = f"{existing_instructions}\n\n{memory_context}"`. Replace with append-to-latest-user-message-tail.
|
||||
- `headroom/transforms/cache_aligner.py` — delete the rewrite path (lines 160-262). Keep the volatile-content detector and the `cache_aligner_warnings` callback that surfaces detected dynamic content (UUIDs, dates, tokens) to a customer-visible log line.
|
||||
- `headroom/proxy/server.py:299` — `cache_aligner.enabled` flag stays default-False; document that turning it on now only affects warnings.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_proxy_system_prompt_immutable.py::test_memory_enabled_does_not_mutate_system` — request with memory enabled; assert outbound system bytes equal inbound system bytes.
|
||||
- `tests/test_proxy_system_prompt_immutable.py::test_memory_context_appears_in_user_tail` — same request; assert memory context appears in the last user message's tail.
|
||||
- `tests/test_cache_aligner_detector_only.py::test_volatile_content_detected_warned_not_rewritten` — system prompt with UUID; assert detector emits warning log; assert system bytes unchanged.
|
||||
|
||||
**Tests deleted/updated:**
|
||||
- `tests/test_cache_aligner_rewrite_*.py` — delete; rewrite path is gone.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Existing memory tests still pass (the live-zone-tail append should produce equivalent semantics).
|
||||
- No regression in `tests/test_proxy_anthropic_cache_stability.py`.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None. Parallel with A1.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B6 (memory subsystem refactor, which builds on this).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert` the merge commit. Memory injection returns to system prompt. P0-1 returns.
|
||||
|
||||
---
|
||||
|
||||
## PR-A3 — Switch Python forwarders to byte-faithful body forwarding
|
||||
|
||||
**Branch:** `realign-A3-byte-faithful-forwarders`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A3-byte-faithful-forwarders`
|
||||
**Risk:** **HIGH** (touches every outbound HTTP call in Python)
|
||||
**LOC:** -200 / +250
|
||||
|
||||
### Scope
|
||||
Eliminate P0-2 universally. Every Python forwarder switches from `httpx ... json=body` to `httpx ... content=raw_bytes`. When body was mutated by a transform, re-serialize once with `separators=(",", ":")`, `ensure_ascii=False`, and the original encoding. When unmutated, forward the original `await request.body()` verbatim.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/server.py:1073-1124` — `_retry_request`: track whether body was mutated; if not, forward `original_body_bytes`; if yes, re-serialize with the canonical settings. Switch `await self.http_client.post(url, json=body)` to `await self.http_client.post(url, content=outbound_bytes, headers={**headers, "content-type": "application/json"})`.
|
||||
- `headroom/proxy/handlers/streaming.py:617-660` — same pattern in `_send_streaming_request`.
|
||||
- `headroom/proxy/handlers/openai.py:2392-2410` — WS→HTTP fallback: same pattern.
|
||||
- `headroom/proxy/handlers/batch.py:340-360` — batch endpoint: same pattern.
|
||||
- `headroom/proxy/helpers.py` — add `serialize_body_canonical(body: dict) -> bytes` helper using `json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")`.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_proxy_byte_faithful_forwarding.py::test_passthrough_no_mutation_byte_equal` — request with no compression / memory / transforms; assert SHA-256 of upstream-received body equals SHA-256 of client-sent body.
|
||||
- `tests/test_proxy_byte_faithful_forwarding.py::test_compression_off_unicode_preserved` — request with `🔥` and CJK chars in user message; assert no `\uXXXX` escaping at upstream.
|
||||
- `tests/test_proxy_byte_faithful_forwarding.py::test_compression_off_numeric_precision_preserved` — request with `temperature: 1.0` and `seed: 12345678901234567`; assert exact bytes.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- New byte-faithful tests pass.
|
||||
- Existing test suite green.
|
||||
- Manual smoke test: send a real request through the proxy with `tcpdump` or a recording mock; verify the bytes hitting upstream match a direct-to-Anthropic baseline.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None. Parallel with A1, A2.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-A6 (memory tool injection refactor relies on the new mutation-tracking helper).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. The httpx `json=` defaults return.
|
||||
|
||||
### Notes
|
||||
|
||||
- This is the highest-impact single PR for cache hit rate. Test coverage carefully.
|
||||
- `httpx.AsyncClient` defaults set Content-Length from the bytes; verify no Transfer-Encoding chunked drift.
|
||||
- The `accept-encoding` header strip stays for now; Phase F PR-F2 makes it conditional on auth mode.
|
||||
|
||||
---
|
||||
|
||||
## PR-A4 — Honor customer `cache_control` markers in Rust; enable `arbitrary_precision`+`raw_value`
|
||||
|
||||
**Branch:** `realign-A4-honor-cache-control`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A4-honor-cache-control`
|
||||
**Risk:** **MEDIUM** (Rust-only; tightly scoped)
|
||||
**LOC:** -30 / +200
|
||||
|
||||
### Scope
|
||||
Eliminate P0-3 and P0-5 directly. In Rust, walk customer-set `cache_control` markers in `system`, `tools`, and `messages`; compute the effective `frozen_message_count`. Switch `serde_json` to `arbitrary_precision` + `raw_value` features. Use `&RawValue` for `messages[*]` so unmodified messages forward as exact byte copies. The `compress_anthropic_request` function is currently a no-op stub (per A1) — this PR adds the cache_control parser as preparation for Phase B.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `Cargo.toml:34` — add features: `serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision", "raw_value"] }`. Run `cargo update -p serde_json`.
|
||||
- `crates/headroom-proxy/src/compression/anthropic.rs` — add `pub fn compute_frozen_count(parsed: &serde_json::Value) -> usize` that walks `messages[*].content[*].cache_control`, `system[*].cache_control`, `tools[*].cache_control` and returns the highest message index whose content contains a marker. (Used by Phase B; currently called only by tests.)
|
||||
- `crates/headroom-core/src/lib.rs` — re-export `compute_frozen_count` for use in Phase B.
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_marker_at_message_3_yields_frozen_count_3`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_in_system_blocks_yields_frozen_count_full_history`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_ttl_1h_before_5m_passes`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_ttl_5m_before_1h_warns_and_passes` (we don't reject, but log per §2.19 ordering rule)
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo build -p headroom-proxy` works with new features.
|
||||
- `cargo test -p headroom-proxy` green.
|
||||
- The `compute_frozen_count` returns 0 for a request with zero markers; returns N for a request with a marker on `messages[N]`.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A1 (the call site needs to be removed before this can land cleanly).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B2 (live-zone block dispatcher uses `compute_frozen_count`).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. The Cargo features stay (harmless).
|
||||
|
||||
### Notes
|
||||
|
||||
- `RawValue` is enabled but not yet consumed in this PR. Phase B PR-B2 wires it.
|
||||
- Per guide §2.19, `1h` markers must precede `5m` markers; we log a warning when the ordering is reversed but don't reject (the customer's request, not ours to validate).
|
||||
|
||||
---
|
||||
|
||||
## PR-A5 — Strip `x-headroom-*` from upstream-bound headers
|
||||
|
||||
**Branch:** `realign-A5-strip-headroom-headers`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A5-strip-headroom-headers`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** -10 / +50
|
||||
|
||||
### Scope
|
||||
Eliminate P5-49. `dict(request.headers.items())` is captured unmodified and forwarded; this PR adds an explicit strip step before any upstream call. Reduces fingerprint surface for subscription detection.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/handlers/anthropic.py:526` — wrap `dict(request.headers.items())` with `_strip_internal_headers(headers)` (new helper).
|
||||
- `headroom/proxy/handlers/openai.py:232-264` — same.
|
||||
- `headroom/proxy/handlers/streaming.py:617` — same.
|
||||
- `headroom/proxy/handlers/batch.py:340` — same.
|
||||
- `headroom/proxy/handlers/gemini.py:31` — same.
|
||||
- `headroom/proxy/helpers.py` — add `_strip_internal_headers` helper. Default strip list: `x-headroom-*` (case-insensitive prefix), plus a hardcoded set of internal flags.
|
||||
- `crates/headroom-proxy/src/headers.rs` — add `strip_internal_headers` to the request-side filter. Document that response-side `X-Headroom-*` injection (which is fine) is unrelated.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_header_isolation.py::test_x_headroom_bypass_not_forwarded`
|
||||
- `tests/test_header_isolation.py::test_x_headroom_mode_not_forwarded`
|
||||
- `tests/test_header_isolation.py::test_x_headroom_user_id_not_forwarded`
|
||||
- `crates/headroom-proxy/tests/integration_headers.rs::x_headroom_request_headers_stripped`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- New tests pass.
|
||||
- Existing client-driven `x-headroom-bypass: true` flow still works (proxy reads it; just doesn't forward).
|
||||
- No legitimate header is stripped (whitelist `x-request-id`, `x-trace-id`, etc. by default — though they aren't `x-headroom-*` so they're untouched).
|
||||
|
||||
### Blocked by
|
||||
|
||||
None. Parallel.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-F2 (auth-mode policy uses this helper).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Headers leak again. Low operational risk.
|
||||
|
||||
---
|
||||
|
||||
## PR-A6 — Pin `anthropic-beta` order; session-stickiness skeleton
|
||||
|
||||
**Branch:** `realign-A6-anthropic-beta-stable`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A6-anthropic-beta-stable`
|
||||
**Risk:** **MEDIUM** (touches memory injection beta-mutation)
|
||||
**LOC:** -40 / +180
|
||||
|
||||
### Scope
|
||||
Eliminate P5-50 and start P5-51. When the proxy mutates `anthropic-beta` (memory injection), the new comma-list is computed deterministically (sort tokens or preserve insertion order with new tokens appended). Add a per-session "betas seen so far" tracker so any beta seen in turn N is included in turn N+1 even if the client drops it.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/handlers/anthropic.py:1162-1168` — replace the ad-hoc concat with a helper `merge_anthropic_beta(client: str, headroom: list[str]) -> str` that splits client's value on `,`, lowercases each token, deduplicates, appends Headroom-required tokens (sorted within the appended group), and rejoins.
|
||||
- `headroom/proxy/server.py` — extend `session_state` (already exists for memory) to track `betas_seen: set[str]` per session. Update on every request; merge into outbound `anthropic-beta` for follow-up requests.
|
||||
- `headroom/proxy/helpers.py` — add `betas_seen_lock` and `update_session_betas` helpers.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_anthropic_beta_session_sticky.py::test_beta_seen_turn_1_present_in_turn_2_even_if_client_drops`
|
||||
- `tests/test_anthropic_beta_session_sticky.py::test_memory_injection_appends_deterministic_order`
|
||||
- `tests/test_anthropic_beta_session_sticky.py::test_client_value_preserved_when_no_injection`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- New tests pass.
|
||||
- Session ID is keyed off the existing session detection (per `headroom/proxy/handlers/anthropic.py:1417`).
|
||||
- The "betas seen" set is bounded (LRU eviction at 1000 sessions).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A3 (relies on byte-faithful forwarder for header-bytes correctness; if A3 is rolled back, this still works but is less effective).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-A7 (memory tool session-stickiness uses the same session-state plumbing).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Beta header drift returns; functional but degraded cache safety.
|
||||
|
||||
---
|
||||
|
||||
## PR-A7 — Memory tool injection session-sticky
|
||||
|
||||
**Branch:** `realign-A7-memory-tool-sticky`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A7-memory-tool-sticky`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** -30 / +150
|
||||
|
||||
### Scope
|
||||
Eliminate the rest of P0-6. Once memory injects a tool into `body["tools"]` for a session, every subsequent request in that session also injects the same tool (same name, same definition bytes). Toggling off mid-session is forbidden.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/memory_tool_adapter.py:625-657` — make injection session-state-aware. The session-state object grows a `memory_tools_injected: bool` and `memory_tools_definition_bytes: bytes` (golden form). On every request: if previously injected, inject again with byte-equal definition.
|
||||
- `headroom/proxy/memory_handler.py:389-398` — same: native-tool path becomes session-sticky.
|
||||
- `headroom/proxy/handlers/anthropic.py:1147-1171` — read session state; either inject all (if previously injected or memory enabled this turn) or none.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_memory_tool_session_sticky.py::test_injection_in_turn_1_repeats_in_turn_2`
|
||||
- `tests/test_memory_tool_session_sticky.py::test_byte_equal_tool_definition_across_turns`
|
||||
- `tests/test_memory_tool_session_sticky.py::test_memory_disabled_after_inject_still_injects`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- New tests pass.
|
||||
- The injected `memory_*` tool definitions are byte-stable across deploys (snapshot test pins the bytes).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A6.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B6 (memory subsystem refactor).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Toggling returns. P0-6 returns.
|
||||
|
||||
---
|
||||
|
||||
## PR-A8 — Hotfix Python wire-format bugs; add SHA-256 round-trip test
|
||||
|
||||
**Branch:** `realign-A8-python-wire-hotfix`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-A8-python-wire-hotfix`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** -100 / +400
|
||||
|
||||
### Scope
|
||||
Catch-all for the Python wire-format bugs that should be fixed before Phase H deletes the Python proxy. Specifically:
|
||||
- P1-8: SSE byte-level decoding in `streaming.py` and `ccr/response_handler.py`.
|
||||
- P1-9: Add `thinking_delta`, `signature_delta`, `citations_delta` arms to `_parse_sse_to_response`.
|
||||
- P0-7 / P4-44: Preserve `phase` field in `responses_converter.py`; fix multi-text-part rebuild.
|
||||
- P5-57 / P5-59: Capture upstream `request-id` in logs; fix body-size-cap status code (400 → 413).
|
||||
- P4-47: Add a warning log line when `responses_converter.py:99` hits an unknown item type.
|
||||
- P6-63: New SHA-256 byte-faithful round-trip test on a recorded production payload.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/handlers/streaming.py:213-298` — rewrite `_parse_sse_to_response` to handle all delta types per guide §5.1. Add index-keyed block map. Bytes-level SSE buffer.
|
||||
- `headroom/proxy/handlers/streaming.py:58, 772` — switch `chunk.decode("utf-8", errors="ignore")` to a bytes-buffer + decode-after-`\n\n` pattern.
|
||||
- `headroom/ccr/response_handler.py:665-686` — same pattern.
|
||||
- `headroom/proxy/responses_converter.py:94, 235` — preserve `phase` explicitly. Fix multi-text-part rebuild: rebuild parts by index, replacing each part's text in place.
|
||||
- `headroom/proxy/responses_converter.py:99` — add `logger.warning(f"unknown responses item type: {item.get('type')}")`.
|
||||
- `crates/headroom-proxy/src/proxy.rs:355-358` — capture upstream `request-id` (Anthropic) and `x-request-id` (OpenAI) into the tracing field.
|
||||
- `crates/headroom-proxy/src/proxy.rs:243-263` — return 413 on body-too-large; return 400 only on actual parse error.
|
||||
|
||||
**Add:**
|
||||
- `tests/fixtures/anthropic_messages_request_real.json` — recorded production-shaped payload (sanitized).
|
||||
- `tests/test_proxy_byte_faithful_round_trip.py::test_sha256_round_trip_no_compression` — boot proxy; send fixture; assert SHA-256 byte-equal at upstream mock.
|
||||
- `tests/test_proxy_responses_phase_preservation.py::test_codex_phase_commentary_preserved`
|
||||
- `tests/test_proxy_responses_phase_preservation.py::test_codex_phase_final_answer_preserved`
|
||||
- `tests/test_sse_thinking_blocks.py::test_thinking_delta_accumulated`
|
||||
- `tests/test_sse_thinking_blocks.py::test_signature_delta_preserved`
|
||||
- `tests/test_sse_thinking_blocks.py::test_citations_delta_accumulated`
|
||||
- `tests/test_sse_utf8_split.py::test_emoji_split_across_chunks_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_request_id.rs::upstream_request_id_captured`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Pre-existing test suite green.
|
||||
- Manual streaming smoke test with thinking blocks + signatures.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None. Parallel with A2-A7.
|
||||
|
||||
### Blocks
|
||||
|
||||
None directly; Phase C builds on the Rust SSE work but doesn't depend on this Python fix.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Wire-format bugs return; subsequent Phase C will re-fix in Rust anyway.
|
||||
|
||||
### Notes
|
||||
|
||||
- This is a "hotfix the Python proxy enough to be safe until Phase H deletes it" PR. Not investing in pretty Python here — just safety.
|
||||
- The recorded fixture in `tests/fixtures/anthropic_messages_request_real.json` should include: thinking + signature blocks, tool_use with non-trivial JSON input, mixed-key schemas, non-ASCII content, large numbers, `cache_control` markers in messages and system.
|
||||
|
||||
---
|
||||
|
||||
## Phase A acceptance summary
|
||||
|
||||
After all 8 PRs land:
|
||||
|
||||
- ✅ ICM no longer drops messages from cache hot zone
|
||||
- ✅ Customer `cache_control` markers honored in Rust
|
||||
- ✅ System prompt never mutated
|
||||
- ✅ Memory context routes to live-zone tail
|
||||
- ✅ Memory tool injection session-sticky
|
||||
- ✅ `anthropic-beta` mutation deterministic + session-sticky
|
||||
- ✅ Python forwarders byte-faithful
|
||||
- ✅ `x-headroom-*` stripped from upstream
|
||||
- ✅ Numeric precision preserved (RawValue + arbitrary_precision)
|
||||
- ✅ SSE thinking/signature/citations deltas handled
|
||||
- ✅ Codex `phase` preserved
|
||||
- ✅ Upstream request-id captured
|
||||
- ✅ SHA-256 byte-faithful round-trip test gating CI
|
||||
|
||||
**Phase A retires P0-1 through P0-7 and P1-8, P1-9, P5-49, P5-50, P5-57, P5-59, P6-63.**
|
||||
@@ -0,0 +1,470 @@
|
||||
# Phase B — Live-Zone-Only Compression Engine
|
||||
|
||||
**Goal:** Delete ~10 K LOC of architectural over-build (ICM, scoring, relevance, rolling-window, progressive-summarizer, tool-crusher); build the correct architecture: per-block compression on the live zone only, with type-aware dispatch, token validation, and CCR hardening.
|
||||
|
||||
**Calendar:** 2 weeks. PR-B1 is the big delete (high-LOC, lower-risk-than-it-looks because the code was unreachable after Phase A). PR-B2..B7 build the replacement.
|
||||
|
||||
**Shape:** 7 PRs. B1 is independent; B2..B5 depend on B1; B6 + B7 layer on B2.
|
||||
|
||||
---
|
||||
|
||||
## PR-B1 — The big delete: retire ICM and its dependencies
|
||||
|
||||
**Branch:** `realign-B1-delete-icm-and-deps`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B1-delete-icm-and-deps`
|
||||
**Risk:** **MEDIUM** (large diff, but most code became unreachable after Phase A PR-A1)
|
||||
**LOC:** **-10,000 / +50** (the big retirement)
|
||||
|
||||
### Scope
|
||||
Delete the wrong-mental-model machinery wholesale. After Phase A PR-A1 made the proxy a passthrough on `/v1/messages`, none of this code is reached at runtime; this PR removes the source so future contributors can't re-wire it.
|
||||
|
||||
### Files
|
||||
|
||||
**Delete (Python):**
|
||||
- `headroom/transforms/intelligent_context.py` (1077 LOC)
|
||||
- `headroom/transforms/rolling_window.py` (395 LOC)
|
||||
- `headroom/transforms/progressive_summarizer.py` (508 LOC)
|
||||
- `headroom/transforms/scoring.py` (459 LOC)
|
||||
- `headroom/transforms/tool_crusher.py` (338 LOC)
|
||||
|
||||
**Delete (Rust):**
|
||||
- `crates/headroom-core/src/context/manager.rs`
|
||||
- `crates/headroom-core/src/context/config.rs`
|
||||
- `crates/headroom-core/src/context/workspace.rs`
|
||||
- `crates/headroom-core/src/context/candidate.rs`
|
||||
- `crates/headroom-core/src/context/ccr_drop.rs`
|
||||
- `crates/headroom-core/src/context/strategy/mod.rs`
|
||||
- `crates/headroom-core/src/context/strategy/drop_by_score.rs`
|
||||
- All of `crates/headroom-core/src/scoring/*.rs` (~1500 LOC)
|
||||
- All of `crates/headroom-core/src/relevance/*.rs` (~1600 LOC)
|
||||
- `crates/headroom-core/.fastembed_cache/` directory and its `bge-small-en-v1.5` ONNX artifacts (~50 MB)
|
||||
|
||||
**Move:**
|
||||
- `crates/headroom-core/src/context/safety.rs` → `crates/headroom-core/src/transforms/safety.rs`. Update all callers' `use` paths. The tool-pair atomicity logic is preserved verbatim (it's correct and live-zone code needs it).
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-core/src/lib.rs` — remove `pub mod context;`, `pub mod scoring;`, `pub mod relevance;`. Add `pub use transforms::safety;`.
|
||||
- `crates/headroom-core/src/context/mod.rs` — delete (empty after move).
|
||||
- `crates/headroom-proxy/src/lib.rs` — no changes (already doesn't reach into deleted modules after PR-A1).
|
||||
- `crates/headroom-py/src/lib.rs` — remove any PyO3 exports of `MessageScorer`, `IntelligentContextManager`, etc. (per agent reports, MessageScorer was exposed in PR #338/#343).
|
||||
- `headroom/transforms/__init__.py` — remove imports of deleted modules.
|
||||
- `headroom/proxy/handlers/anthropic.py` — remove all imports / call sites of `IntelligentContextManager`. (Agent C found these at multiple locations; track via `grep -n IntelligentContextManager headroom/`.)
|
||||
- `headroom/proxy/server.py` — remove ICM import and instantiation.
|
||||
- `Cargo.toml` workspace dependencies — drop `fastembed`, `tantivy`, `ort` (if only used by relevance), and any other deps that become orphaned.
|
||||
|
||||
**Tests deleted:**
|
||||
- All `tests/test_intelligent_context*.py`
|
||||
- All `tests/test_rolling_window*.py`
|
||||
- All `tests/test_progressive_summarizer*.py`
|
||||
- All `tests/test_scoring*.py`
|
||||
- All `tests/test_tool_crusher*.py`
|
||||
- `crates/headroom-core/tests/scoring_*.rs`, `relevance_*.rs`, `context_*.rs` (other than safety)
|
||||
- Parity comparator for `message_scorer` (PR #338/#343 work) — delete the comparator and the fixtures it consumed.
|
||||
- Parity fixtures `tests/parity/fixtures/message_scorer/` (13 fixtures per Agent F report).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo build --workspace` green.
|
||||
- `cargo test --workspace` green (after test deletions).
|
||||
- `make ci-precheck` green.
|
||||
- `pytest -x` green.
|
||||
- Workspace builds **without** the fastembed cache dir.
|
||||
- `git grep -i "IntelligentContextManager\|MessageScorer\|RollingWindow\|ProgressiveSummarizer\|ToolCrusher\|DropByScoreStrategy"` returns nothing in `crates/`, `headroom/`, `tests/` except comments referencing the deletion.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A1 (ICM call site must be removed first).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B2 (live-zone block dispatcher fills the void).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. ~10K LOC returns. Cache-killer bugs DO NOT return because Phase A PR-A1 already removed the call site (the deleted code is unreachable). Safe to revert.
|
||||
|
||||
### Notes
|
||||
|
||||
- **MessageScorer Rust port retirement:** PR #338 and #343 (April 2026) ported MessageScorer to Rust. That work becomes deletable here. Sunk cost stays sunk. The fixtures and the parity-harness scaffolding learnings carry forward to live-zone work.
|
||||
- **`bge-small-en-v1.5` ONNX cache:** ~50 MB. Removing it is reversible (re-fetched on next fastembed init if anyone re-adds the dep). Document in CHANGELOG.
|
||||
- **`anchor_selector.py`** — Agent G suspected it might be ICM-only. Check: `git grep AnchorSelector headroom/ crates/`. If only consumed by ICM/scoring/SmartCrusher, delete; if consumed by SmartCrusher's anchor logic, keep. Current Rust has `crates/headroom-core/src/transforms/anchor_selector.rs` which is consumed by SmartCrusher — keep that one, delete the Python one if ICM was its only caller.
|
||||
|
||||
---
|
||||
|
||||
## PR-B2 — Live-zone block dispatcher in Rust
|
||||
|
||||
**Branch:** `realign-B2-live-zone-dispatcher`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B2-live-zone-dispatcher`
|
||||
**Risk:** **MEDIUM-HIGH** (new architecture; the central piece)
|
||||
**LOC:** +800
|
||||
|
||||
### Scope
|
||||
Build the new compressor: a function that takes an Anthropic `/v1/messages` body, identifies the live-zone blocks (latest user message tool_results, latest user message text, latest assistant tool_use is hot zone — exclude), and dispatches each to a type-aware compressor. Does NOT yet wire the type-aware compressors (PR-B3); does NOT yet validate tokens (PR-B4); does NOT yet inject CCR (PR-B7). PR-B2 lays the dispatching skeleton with no-op compressors; subsequent PRs fill them in.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-core/src/transforms/live_zone.rs` — the dispatcher. Public API:
|
||||
```rust
|
||||
pub fn compress_live_zone(
|
||||
body_raw: &serde_json::value::RawValue,
|
||||
frozen_message_count: usize,
|
||||
auth_mode: AuthMode,
|
||||
) -> Result<LiveZoneOutcome>;
|
||||
|
||||
pub enum LiveZoneOutcome {
|
||||
NoChange,
|
||||
Modified { new_body: Box<serde_json::value::RawValue>, manifest: CompressionManifest },
|
||||
}
|
||||
```
|
||||
Implementation skeleton:
|
||||
1. Parse `body` minimally (only `messages` field; leave the rest as `RawValue`).
|
||||
2. For each message at index `>= frozen_message_count`:
|
||||
- Identify if it's the latest user message (live zone candidate).
|
||||
- For each block in its content:
|
||||
- If block type is `tool_result`, dispatch to a no-op compressor (filled in PR-B3).
|
||||
- If block type is `text`, dispatch to text compressor (no-op for now).
|
||||
- Otherwise (image, etc.), no-op.
|
||||
3. Reassemble the modified `messages` array, preserving unmodified messages as `RawValue` byte-copies.
|
||||
4. Reassemble the body, preserving the original envelope as `RawValue` byte-copies; the only modified bytes are within the messages array.
|
||||
5. Return `Modified` only if any block was actually mutated; otherwise `NoChange` and the caller forwards original bytes.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/mod.rs` — add `pub mod live_zone_anthropic;` and route `/v1/messages` to it (replacing the no-op stub from PR-A1).
|
||||
- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` (new) — calls `compress_live_zone` with `frozen_count = compute_frozen_count(parsed)` (from PR-A4).
|
||||
- `crates/headroom-proxy/src/compression/anthropic.rs` — delete (replaced by `live_zone_anthropic.rs`).
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-core/tests/live_zone_skeleton.rs::dispatches_only_to_latest_user_message`
|
||||
- `crates/headroom-core/tests/live_zone_skeleton.rs::respects_frozen_message_count`
|
||||
- `crates/headroom-core/tests/live_zone_skeleton.rs::no_change_when_no_block_mutated_returns_original`
|
||||
- `crates/headroom-core/tests/live_zone_skeleton.rs::modified_messages_byte_equal_outside_block`
|
||||
- `crates/headroom-core/tests/live_zone_skeleton.rs::system_and_tools_byte_equal_always`
|
||||
- `crates/headroom-proxy/tests/integration_live_zone.rs::end_to_end_live_zone_passthrough`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo test -p headroom-core` green.
|
||||
- `cargo test -p headroom-proxy` green.
|
||||
- All Phase A SHA-256 tests still pass (live-zone with no-op compressors is byte-identical).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B1 (deletion); PR-A4 (cache_control / RawValue features).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B3, PR-B4, PR-B7.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Compression returns to passthrough (the Phase A state); no functional regression.
|
||||
|
||||
### Notes
|
||||
|
||||
- The `RawValue`-based approach is the correctness mechanism: bytes outside modified blocks are byte-copies, not parse-then-reserialize.
|
||||
- `AuthMode` parameter is unused in B2 (always `Payg` from B2's perspective); Phase F PR-F2 wires the gate.
|
||||
|
||||
---
|
||||
|
||||
## PR-B3 — Wire type-aware compressors into live-zone dispatcher
|
||||
|
||||
**Branch:** `realign-B3-wire-type-aware-compressors`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B3-wire-type-aware-compressors`
|
||||
**Risk:** **MEDIUM** (existing compressors are battle-tested)
|
||||
**LOC:** +600
|
||||
|
||||
### Scope
|
||||
Wire `SmartCrusher`, `LogCompressor`, `SearchCompressor`, `DiffCompressor`, `CodeCompressor` into the dispatcher. Per-block content-type detection drives dispatch. No token validation yet (PR-B4); no CCR hardening yet (PR-B7).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-core/src/transforms/live_zone.rs` — replace no-op compressors with real dispatch:
|
||||
```rust
|
||||
fn compress_block(block: &mut Block, content_type: ContentType) -> Result<Option<CompressionResult>> {
|
||||
match content_type {
|
||||
ContentType::JsonArrayOfDicts => smart_crusher::crush(block),
|
||||
ContentType::Logs => log_compressor::compress(block),
|
||||
ContentType::SearchResults => search_compressor::compress(block),
|
||||
ContentType::Diff => diff_compressor::compress(block),
|
||||
ContentType::SourceCode => code_compressor::compress(block),
|
||||
ContentType::PlainText => Ok(None), // PR-B4 adds Kompress; for now, leave untouched
|
||||
ContentType::Image | ContentType::Unknown => Ok(None),
|
||||
}
|
||||
}
|
||||
```
|
||||
- `crates/headroom-core/src/transforms/content_detector.rs` — extend `ContentType` enum with the variants above. Use existing `Magika` + `unidiff-rs` + heuristic detectors.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-core/tests/live_zone_dispatch.rs::json_tool_result_routes_to_smart_crusher`
|
||||
- `crates/headroom-core/tests/live_zone_dispatch.rs::log_tool_result_routes_to_log_compressor`
|
||||
- `crates/headroom-core/tests/live_zone_dispatch.rs::diff_tool_result_routes_to_diff_compressor`
|
||||
- `crates/headroom-core/tests/live_zone_dispatch.rs::source_code_tool_result_routes_to_code_compressor`
|
||||
- `crates/headroom-core/tests/live_zone_dispatch.rs::unknown_content_type_no_op`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Existing SmartCrusher / LogCompressor / DiffCompressor / SearchCompressor tests still pass (their code is unchanged; only the caller is new).
|
||||
- A representative `/v1/messages` request with a 50KB JSON tool_result through the proxy results in measurable compression (>2× size reduction) and SHA-256-equal envelope outside the compressed block.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B2.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B4 (token validation gate), PR-B7 (CCR injection).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Live-zone goes back to no-op compressors.
|
||||
|
||||
---
|
||||
|
||||
## PR-B4 — Token validation gate with fallback; per-content-type byte thresholds
|
||||
|
||||
**Branch:** `realign-B4-token-validation-gate`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B4-token-validation-gate`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +250
|
||||
|
||||
### Scope
|
||||
Eliminate P3-33 and P3-34. After every per-block compression, run the tokenizer over `original` and `compressed`. If `compressed.tokens >= original.tokens`, fall back to original. Add per-content-type byte thresholds: code>2KB, JSON>1KB, logs>500B, plain text>5KB. Below threshold → no compression attempted.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-core/src/transforms/live_zone.rs` — wrap each compressor call with:
|
||||
```rust
|
||||
let original_tokens = tokenizer.count(&original_bytes)?;
|
||||
let compressed_tokens = tokenizer.count(&compressed_bytes)?;
|
||||
if compressed_tokens >= original_tokens {
|
||||
metrics::compression_rejected_by_token_check(compressor_name);
|
||||
return Ok(None); // fall back to original
|
||||
}
|
||||
```
|
||||
- `crates/headroom-core/src/transforms/live_zone.rs::compress_block` — gate on byte threshold per content type:
|
||||
```rust
|
||||
const THRESHOLDS: &[(ContentType, usize)] = &[
|
||||
(ContentType::SourceCode, 2048),
|
||||
(ContentType::JsonArrayOfDicts, 1024),
|
||||
(ContentType::Logs, 512),
|
||||
(ContentType::PlainText, 5120),
|
||||
(ContentType::Diff, 1024),
|
||||
(ContentType::SearchResults, 1024),
|
||||
];
|
||||
if block.bytes_len() < threshold_for(content_type) {
|
||||
return Ok(None);
|
||||
}
|
||||
```
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-core/tests/live_zone_thresholds.rs::below_threshold_no_compression_attempted`
|
||||
- `crates/headroom-core/tests/live_zone_thresholds.rs::above_threshold_compression_attempted`
|
||||
- `crates/headroom-core/tests/live_zone_token_validation.rs::compressed_more_tokens_falls_back`
|
||||
- `crates/headroom-core/tests/live_zone_token_validation.rs::compressed_fewer_tokens_accepted`
|
||||
- Property test: `proptest! { fn live_zone_compression_token_count_non_increasing(blocks in arb_blocks_strategy()) { ... } }`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All tests pass.
|
||||
- A pathological input (already-minified JSON, dense base64) falls back to original instead of inflating tokens.
|
||||
- Prometheus emits `compression_rejected_by_token_check_total{strategy=...}` counter.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B3.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-B6, PR-B7.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Token validation removed; bytes-only gate returns. Slight regression risk on pathological inputs.
|
||||
|
||||
---
|
||||
|
||||
## PR-B5 — TOIN observation-only refactor
|
||||
|
||||
**Branch:** `realign-B5-toin-observation-only`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B5-toin-observation-only`
|
||||
**Risk:** **MEDIUM** (TOIN is a preserved primitive; refactor must maintain its learning value)
|
||||
**LOC:** -300 / +400
|
||||
|
||||
### Scope
|
||||
Eliminate P2-27 and P5-56. Strip TOIN's request-time hint API; keep the recording API. Recommendations published between deploys via a CLI tool that aggregates and writes a TOML file the compressor loads at startup. Per-tenant aggregation key extended to `(auth_mode, model_family, structure_hash)`.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/telemetry/toin.py:853-927` — remove `get_recommendation()` and `CompressionHint`. Replace with a no-op stub that returns `None`; deprecation warning in docstring.
|
||||
- `headroom/telemetry/toin.py:103` — `Pattern` adds `auth_mode: str`, `model_family: str` fields.
|
||||
- `headroom/telemetry/toin.py:477, 496, 727, 729, 1248, 1256` — change aggregation key from `sig_hash` to `(auth_mode, model_family, sig_hash)` tuple. Update all dict-key uses.
|
||||
- `headroom/telemetry/toin.py:1596` — keep `tenant_prefix` for storage but document it's now redundant with the aggregation key.
|
||||
- `headroom/transforms/smart_crusher.py:446` — remove the `get_recommendation()` call site. SmartCrusher is now deterministic; TOIN observes outcomes only.
|
||||
- New CLI: `headroom/cli/toin_publish.py` — aggregates the on-disk TOIN store and produces `recommendations.toml`. Run as part of the deploy pipeline.
|
||||
- New: `crates/headroom-core/src/transforms/recommendations.rs` — loads `recommendations.toml` at startup. Provides API like `recommendations::get(auth_mode, model, structure_hash) -> Option<Recommendation>`. Used to bias which compressor variants to try first (deterministic; no per-request mutation).
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_toin_observation_only.py::test_no_request_time_hint_api_exposed`
|
||||
- `tests/test_toin_observation_only.py::test_aggregation_key_includes_auth_mode_and_model`
|
||||
- `tests/test_toin_observation_only.py::test_record_does_not_alter_compression_decision`
|
||||
- `tests/test_toin_publish.py::test_publish_command_writes_toml`
|
||||
- Determinism property test: `proptest! { fn compressor_deterministic_under_toin(input in arb_input()) { let r1 = compress(input); let r2 = compress(input); assert_eq!(r1, r2); } }`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- TOIN's `record_compression` call sites still work (recording is kept).
|
||||
- Removing TOIN's recommendations.toml at startup makes compression behave as if TOIN had never observed anything (graceful degrade).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B4.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-F3 (auth-mode aggregation key requires the TOIN refactor).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Per-request hint API returns; non-determinism returns.
|
||||
|
||||
### Notes
|
||||
|
||||
- This PR preserves TOIN per user direction: the learning value is intact; the dangerous request-time mutation is gone.
|
||||
|
||||
---
|
||||
|
||||
## PR-B6 — Memory subsystem refactor: live-zone tail injection only
|
||||
|
||||
**Branch:** `realign-B6-memory-live-zone-tail`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B6-memory-live-zone-tail`
|
||||
**Risk:** **MEDIUM-HIGH** (touches memory feature semantics)
|
||||
**LOC:** -400 / +300
|
||||
|
||||
### Scope
|
||||
Eliminate P2-24. Memory retrieval moves out of the request lifecycle "auto-prepend" position. Two modes:
|
||||
1. **Auto-tail mode** (default for now): retrieval runs at request entry; results appended to the latest user message tail (live zone). Same content always positions at the same place. Deterministic results for the same query.
|
||||
2. **Tool mode** (preferred long-term): the model calls `memory_search` explicitly; retrieval runs in the tool execution path, not in the prompt-construction path. Memory is opt-in, not invisible.
|
||||
|
||||
This PR ships auto-tail-mode as default; tool-mode is wired but off-by-default.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/memory_handler.py:498-510` — delete `_inject_to_system_or_instructions`. Replace with `_append_to_latest_user_tail`.
|
||||
- `headroom/proxy/handlers/openai.py:535-540` — same.
|
||||
- `headroom/proxy/handlers/anthropic.py:1117-1135` — promote the existing `_append_context_to_latest_non_frozen_user_turn` to be the default path.
|
||||
- `headroom/proxy/server.py:1050-1058` — already deleted in PR-A2; verify nothing reintroduces it.
|
||||
- `headroom/proxy/memory_handler.py` — add `MemoryMode` enum: `AutoTail | Tool`. Default `AutoTail`. `Tool` mode skips auto-injection entirely.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_memory_auto_tail.py::test_memory_appears_in_latest_user_message_tail`
|
||||
- `tests/test_memory_auto_tail.py::test_memory_does_not_modify_system_or_tools`
|
||||
- `tests/test_memory_auto_tail.py::test_same_query_byte_identical_across_runs`
|
||||
- `tests/test_memory_tool_mode.py::test_tool_mode_skips_auto_injection`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Existing memory feature tests pass (semantics preserved; position changes from system to user-tail).
|
||||
- The bytes inserted are deterministic for the same query (no randomness in vector search results — verify or seed).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A2, PR-B4.
|
||||
|
||||
### Blocks
|
||||
|
||||
None (memory tool injection session-stickiness from PR-A7 stays).
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Memory returns to auto-prepend.
|
||||
|
||||
---
|
||||
|
||||
## PR-B7 — CCR hardening: persistent backend + always-on tool registration
|
||||
|
||||
**Branch:** `realign-B7-ccr-hardening`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-B7-ccr-hardening`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** -150 / +600
|
||||
|
||||
### Scope
|
||||
Eliminate P2-25, P2-26. Two changes:
|
||||
1. **Persistent CCR backend.** `CcrStore` trait gets a `SqliteCcrStore` impl (default) and a `RedisCcrStore` impl (opt-in for multi-worker). The in-memory store stays for tests. RUST_DEV.md "Multi-worker deployment — CCR fragmentation" section gets updated.
|
||||
2. **`ccr_retrieve` tool always-on.** Once a session has performed any CCR compression, the tool is registered in `body["tools"]` for every subsequent request. The session ID derives from the existing `session_tracker_store` plumbing.
|
||||
|
||||
In Rust, the live-zone dispatcher writes `<<ccr:HASH>>` markers into the compressed block content (side-channel) and stores the original bytes in the configured backend.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-core/src/ccr/backends/sqlite.rs` — SQLite-backed `CcrStore`. Schema: `ccr_entries(hash TEXT PRIMARY KEY, original BLOB, created_at INTEGER, ttl_seconds INTEGER)`. Auto-purge on read (`WHERE created_at + ttl_seconds > now`).
|
||||
- `crates/headroom-core/src/ccr/backends/redis.rs` — Redis-backed `CcrStore`. `SETEX hash ttl_seconds original`.
|
||||
- `crates/headroom-core/src/ccr/backends/mod.rs` — `pub trait CcrStore` (already exists at `ccr.rs`); `pub fn from_config(config: &CcrConfig) -> Box<dyn CcrStore>`.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-core/src/ccr.rs` — extract `InMemoryCcrStore` to its own file; rest stays.
|
||||
- `crates/headroom-core/src/transforms/live_zone.rs` — when a compressor returns a `CompressionResult` with original bytes, store original bytes in CCR backend keyed by `BLAKE3(original_bytes)`. Append `<<ccr:HASH>>` marker to compressed block content.
|
||||
- `headroom/proxy/handlers/anthropic.py` — `inject_ccr_retrieve_tool`: always add the tool when `session.has_done_ccr` is true; never toggle off.
|
||||
- `headroom/proxy/handlers/openai.py` — same for OpenAI Chat / Responses.
|
||||
- `headroom/ccr/tool_injection.py:302-328` — change `if has_compressed_content:` to `if session.has_done_ccr:`.
|
||||
- `RUST_DEV.md` — update "Multi-worker deployment — CCR fragmentation" section: with `SqliteCcrStore` + sticky-session not required; with `RedisCcrStore` no stickiness needed at all.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-core/tests/ccr_backends.rs::sqlite_round_trip`
|
||||
- `crates/headroom-core/tests/ccr_backends.rs::sqlite_ttl_purge`
|
||||
- `crates/headroom-core/tests/ccr_backends.rs::redis_round_trip` (gated behind `cfg(feature = "redis")`)
|
||||
- `crates/headroom-core/tests/ccr_backends.rs::backend_swap_byte_equal_keys`
|
||||
- `tests/test_ccr_tool_always_on.py::test_tool_registered_on_every_request_after_first_ccr`
|
||||
- `tests/test_ccr_tool_always_on.py::test_tool_not_registered_if_session_never_did_ccr`
|
||||
- `tests/test_ccr_tool_always_on.py::test_tool_definition_byte_stable`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- `RUST_DEV.md` reflects the new multi-worker story.
|
||||
- A simulated proxy restart (kill + restart with `SqliteCcrStore`) can still resolve CCR markers from before the restart.
|
||||
- Tool definition bytes are byte-stable (snapshot test pins them).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B2, PR-B3, PR-B4.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. In-memory-only CCR returns; tool-list flip returns. Operations stays — just less safe.
|
||||
|
||||
### Notes
|
||||
|
||||
- The `<<ccr:HASH>>` marker format is unchanged — existing markers from before this PR (in any cached prefix) still work.
|
||||
- The session ID for "has done CCR" is the existing `session_id` from `session_tracker_store`; no new persistence needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase B acceptance summary
|
||||
|
||||
After all 7 PRs land:
|
||||
|
||||
- ✅ ICM + RollingWindow + ProgressiveSummarizer + scoring + relevance + ToolCrusher deleted (~10K LOC retired)
|
||||
- ✅ Live-zone block dispatcher operational
|
||||
- ✅ Type-aware compressors wired (SmartCrusher, LogCompressor, SearchCompressor, DiffCompressor, CodeCompressor)
|
||||
- ✅ Token validation gate with per-type byte thresholds and fallback
|
||||
- ✅ TOIN observation-only with per-tenant aggregation key
|
||||
- ✅ Memory routes to live-zone tail (no system mutation)
|
||||
- ✅ CCR persistent backend + always-on tool registration
|
||||
- ✅ MessageScorer Rust port (PR #338, #343) retired
|
||||
|
||||
**Phase B retires P0-4, P1-13, P2-18 through P2-27, P3-33, P3-34, P5-56, P6-70.**
|
||||
|
||||
After Phase B, Headroom's compression value is **back online** — and now it's correct.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Phase C — Rust Proxy Paths
|
||||
|
||||
**Goal:** Port the remaining proxy surfaces to Rust. After Phase C, the Rust proxy handles `/v1/messages`, `/v1/chat/completions`, `/v1/responses` (HTTP + streaming), with a byte-level SSE state machine that handles every wire-format quirk the guide enumerates.
|
||||
|
||||
**Calendar:** 3 weeks. Mostly sequential (each PR builds on the SSE parser).
|
||||
|
||||
**Shape:** 5 PRs.
|
||||
|
||||
---
|
||||
|
||||
## PR-C1 — Byte-level SSE parser with full state machine
|
||||
|
||||
**Branch:** `realign-C1-rust-sse-parser`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-C1-rust-sse-parser`
|
||||
**Risk:** **HIGH** (foundational; many wire-format quirks; UTF-8 split-byte handling)
|
||||
**LOC:** +1500
|
||||
|
||||
### Scope
|
||||
Eliminate P1-8, P1-9, P1-14, P1-15, P1-17, P4-48. Build the byte-level SSE parser in `crates/headroom-proxy/src/sse/`. Three parsers (one per provider × API), all sharing a common framing layer. Per-stream state (no module-level state). Models the streaming state machines from guide §5 exactly.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/sse/mod.rs` — module re-exports.
|
||||
- `crates/headroom-proxy/src/sse/framing.rs` — byte-level framing. Reads `bytes::Bytes` chunks, accumulates into a `BytesMut` buffer, finds `\n\n` event terminators in bytes (not strings), yields complete events as `(event_name: Option<String>, data: Bytes)`. Decodes UTF-8 per complete event, never per chunk. Handles `: ping` keepalives (skip silently). Handles `[DONE]` literal.
|
||||
- `crates/headroom-proxy/src/sse/anthropic.rs` — Anthropic stream state machine per guide §5.1:
|
||||
```rust
|
||||
pub struct AnthropicStreamState {
|
||||
pub message_id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub blocks: HashMap<usize, BlockState>, // keyed by index
|
||||
pub current_block_index: Option<usize>,
|
||||
pub stop_reason: Option<String>,
|
||||
pub usage: UsageBuilder,
|
||||
pub status: StreamStatus,
|
||||
}
|
||||
|
||||
pub struct BlockState {
|
||||
pub block_type: String,
|
||||
pub text_buffer: String,
|
||||
pub partial_json: String,
|
||||
pub signature: Option<String>,
|
||||
pub citations: Vec<Citation>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl AnthropicStreamState {
|
||||
pub fn apply(&mut self, event: SseEvent) -> Result<()>;
|
||||
}
|
||||
```
|
||||
Handlers for `message_start`, `content_block_start`, `content_block_delta` (switching on `delta.type`: `text_delta` / `thinking_delta` / `input_json_delta` / `citations_delta` / `signature_delta`), `content_block_stop`, `message_delta`, `message_stop`, `error`, `ping`.
|
||||
- `crates/headroom-proxy/src/sse/openai_chat.rs` — OpenAI Chat Completions state machine per guide §5.2. `ChunkState`, `ChoiceState`, `ToolCallState`. Handles `[DONE]` and `stream_options.include_usage` final chunk.
|
||||
- `crates/headroom-proxy/src/sse/openai_responses.rs` — OpenAI Responses state machine per guide §5.3. `ResponseState`, `ItemState` keyed by `id` (not position) for out-of-order completion. Handlers for `response.created`, `output_item.added/done`, `content_part.added/done`, `output_text.delta/done`, `function_call_arguments.delta/done`, `reasoning_summary.delta/done`, `response.completed/failed/incomplete`.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/proxy.rs` — when forwarding a streaming response, the state machine runs in parallel with the byte-passthrough (so client gets raw bytes immediately; state machine populates telemetry without blocking the stream).
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/sse_framing.rs::utf8_split_emoji_across_chunks_preserved`
|
||||
- `crates/headroom-proxy/tests/sse_framing.rs::single_newline_does_not_emit_event`
|
||||
- `crates/headroom-proxy/tests/sse_framing.rs::double_newline_emits_event`
|
||||
- `crates/headroom-proxy/tests/sse_framing.rs::ping_keepalive_skipped`
|
||||
- `crates/headroom-proxy/tests/sse_framing.rs::done_sentinel_detected`
|
||||
- `crates/headroom-proxy/tests/sse_framing.rs::trailing_data_after_done_tolerated`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::four_event_dance_text_block`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::thinking_delta_accumulated`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::signature_delta_preserved_byte_equal`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::input_json_delta_concatenated_parsed_at_stop`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::citations_delta_accumulated`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::message_delta_finalizes_stop_reason_and_output_tokens`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::mid_stream_error_event_handled`
|
||||
- `crates/headroom-proxy/tests/sse_anthropic.rs::interleaved_blocks_by_index`
|
||||
- `crates/headroom-proxy/tests/sse_openai_chat.rs::tool_call_id_and_name_only_first_chunk`
|
||||
- `crates/headroom-proxy/tests/sse_openai_chat.rs::tool_call_arguments_concatenated`
|
||||
- `crates/headroom-proxy/tests/sse_openai_chat.rs::usage_in_final_chunk_when_include_usage_set`
|
||||
- `crates/headroom-proxy/tests/sse_openai_chat.rs::refusal_field_handled`
|
||||
- `crates/headroom-proxy/tests/sse_openai_responses.rs::out_of_order_item_completion_by_id`
|
||||
- `crates/headroom-proxy/tests/sse_openai_responses.rs::reasoning_summary_accumulated`
|
||||
- `crates/headroom-proxy/tests/sse_openai_responses.rs::function_call_arguments_string_preserved`
|
||||
- Property test: `proptest! { fn sse_parser_no_panic_on_arbitrary_bytes(bytes in any::<Vec<u8>>()) { let _ = parse(bytes); } }`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Property test: 100K random byte sequences never panic the parser.
|
||||
- Real-traffic shadow test: feed a recorded production Anthropic stream through both the Rust parser and the Python parser; assert telemetry agrees on `usage` totals.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A1.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-C2, PR-C3, PR-C4.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. SSE parsing returns to byte-passthrough (Phase A state). Telemetry less rich but no functional regression.
|
||||
|
||||
---
|
||||
|
||||
## PR-C2 — `/v1/chat/completions` handler in Rust
|
||||
|
||||
**Branch:** `realign-C2-rust-chat-completions`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-C2-rust-chat-completions`
|
||||
**Risk:** **HIGH** (new endpoint surface)
|
||||
**LOC:** +1200
|
||||
|
||||
### Scope
|
||||
Add `/v1/chat/completions` to `crates/headroom-proxy`. Handles request-body shape, live-zone compression dispatch (assistant `tool_calls` and `tool` role messages — equivalents of Anthropic's tool_result), and the streaming state machine from PR-C1. Adds Phase E PR-E1/E2 tool-def normalization gate (no-op until Phase E).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/handlers/chat_completions.rs` — POST handler.
|
||||
```rust
|
||||
async fn handle_chat_completions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response>;
|
||||
```
|
||||
- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — OpenAI live-zone dispatcher. Live zone for Chat Completions: latest `tool` role message's `content`; latest `user` message's text content. Compress per type-aware dispatch (same compressors as Anthropic; reused).
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/lib.rs` — route `/v1/chat/completions` (POST) to the new handler.
|
||||
- `crates/headroom-proxy/src/compression/mod.rs` — add OpenAI Chat dispatch path.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::passthrough_no_compression_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::tool_message_compressed`
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::n_greater_than_one_passthrough`
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::stream_options_include_usage_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::tool_choice_change_passthrough_no_mutation`
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::refusal_field_in_response_handled`
|
||||
- `crates/headroom-proxy/tests/integration_chat_completions.rs::streaming_tool_call_argument_accumulation`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- A real Chat Completions request through the Rust proxy produces byte-equal upstream bytes when compression is off.
|
||||
- Streaming tool_call accumulation works for the `delta.tool_calls[].function.arguments` pattern.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C1, PR-B3, PR-B4.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-C3, PR-H1.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. `/v1/chat/completions` still flows through the Python proxy (Phase H hasn't deleted it yet).
|
||||
|
||||
---
|
||||
|
||||
## PR-C3 — `/v1/responses` handler in Rust (HTTP)
|
||||
|
||||
**Branch:** `realign-C3-rust-responses-http`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-C3-rust-responses-http`
|
||||
**Risk:** **HIGH**
|
||||
**LOC:** +1500
|
||||
|
||||
### Scope
|
||||
Add `/v1/responses` HTTP handler. Item-shape passthrough preservation for every Responses item type (V4A patches, `local_shell_call.action.command` argv, Codex `phase`, `compaction`, MCP items, computer_use, `image_generation_call`, server-side tool results). Live-zone compression for `function_call_output`, `local_shell_call_output`, `apply_patch_call_output` (only when >2KB).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/handlers/responses.rs` — POST handler.
|
||||
- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — Responses live-zone dispatcher. Live zone: latest `function_call_output.output`, latest `local_shell_call_output.output`, latest `apply_patch_call_output.output`, latest `user` message text content.
|
||||
- `crates/headroom-proxy/src/responses_items.rs` — explicit per-item-type enum and passthrough rules:
|
||||
```rust
|
||||
pub enum ResponseItem {
|
||||
Message { phase: Option<String>, .. },
|
||||
Reasoning { encrypted_content: Option<String>, .. }, // passthrough only
|
||||
FunctionCall { call_id: String, arguments: String, .. }, // arguments stays as string
|
||||
LocalShellCall { command: Vec<String>, .. }, // argv array preserved
|
||||
ApplyPatchCall { operation: ApplyPatchOperation, .. }, // V4A diff verbatim
|
||||
Compaction { encrypted_content: String, .. }, // passthrough only
|
||||
McpCall { .. } | McpListTools { .. } | McpApprovalRequest { .. }, // passthrough
|
||||
ComputerCall { .. } | ComputerCallOutput { .. },
|
||||
WebSearchCall { .. } | FileSearchCall { .. } | CodeInterpreterCall { .. },
|
||||
ImageGenerationCall { .. },
|
||||
ToolSearchCall { .. },
|
||||
CustomToolCall { .. },
|
||||
Unknown { type_: String, raw: Box<RawValue> }, // log warning; preserve verbatim
|
||||
}
|
||||
```
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/lib.rs` — route `/v1/responses` (POST) to the new handler.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::v4a_patch_byte_equal_through_proxy`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::local_shell_call_command_argv_array_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::codex_phase_commentary_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::codex_phase_final_answer_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::compaction_item_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::reasoning_encrypted_content_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::function_call_arguments_string_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::call_id_referenced_not_id`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::apply_patch_output_below_2kb_no_compression`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::apply_patch_output_above_2kb_compressed`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::local_shell_output_compressed`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::mcp_tool_call_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::computer_call_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::image_generation_call_no_log_redaction_in_test_mode`
|
||||
- `crates/headroom-proxy/tests/integration_responses.rs::unknown_item_type_logged_warning_byte_equal`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- A representative Responses request with reasoning + function_call + local_shell + apply_patch + custom items round-trips byte-equal modulo compressed live-zone outputs.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C1, PR-C2.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-C4.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. `/v1/responses` still flows through Python.
|
||||
|
||||
---
|
||||
|
||||
## PR-C4 — `/v1/responses` streaming + Conversations API awareness
|
||||
|
||||
**Branch:** `realign-C4-rust-responses-streaming`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-C4-rust-responses-streaming`
|
||||
**Risk:** **MEDIUM-HIGH**
|
||||
**LOC:** +800
|
||||
|
||||
### Scope
|
||||
Streaming for `/v1/responses` using the SSE state machine from PR-C1. Plus first-class awareness of the Conversations API (P4-40) — when `conversation: {"id": "conv_..."}` is in the body, the local view is incomplete; tokenizer must adjust or skip compression decisions.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/handlers/responses.rs` — when `Accept: text/event-stream`, route to streaming handler. The streaming handler runs the `OpenAIResponsesStreamState` machine in parallel with byte-passthrough.
|
||||
- `crates/headroom-proxy/src/sse/openai_responses.rs` — add usage extraction from `response.completed`.
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/conversations.rs` — detect `conversation: {"id": "conv_..."}` in request body. When present, log a warning and disable live-zone compression for that request (until Phase 4 cross-request shared cache lands). Telemetry: `proxy_conversations_api_request_count_total`.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_responses_streaming.rs::reasoning_summary_streamed_correctly`
|
||||
- `crates/headroom-proxy/tests/integration_responses_streaming.rs::function_call_arguments_streamed_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_responses_streaming.rs::out_of_order_items_handled_by_id`
|
||||
- `crates/headroom-proxy/tests/integration_responses_streaming.rs::response_completed_usage_captured`
|
||||
- `crates/headroom-proxy/tests/integration_responses_streaming.rs::response_failed_handled`
|
||||
- `crates/headroom-proxy/tests/integration_responses_streaming.rs::response_incomplete_with_max_output_tokens_reason`
|
||||
- `crates/headroom-proxy/tests/integration_conversations.rs::conversation_id_present_skips_compression_warns`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- The Conversations API warning appears in logs at `INFO` level with a `conversation_id` field.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C3.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H1.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Streaming Responses routes through Python.
|
||||
|
||||
---
|
||||
|
||||
## PR-C5 — `responses_converter.py` retirement (Rust handles it natively)
|
||||
|
||||
**Branch:** `realign-C5-retire-responses-converter`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-C5-retire-responses-converter`
|
||||
**Risk:** **LOW** (cleanup; Rust handler from PR-C3/C4 covers this surface)
|
||||
**LOC:** -267 / +20
|
||||
|
||||
### Scope
|
||||
Delete `headroom/proxy/responses_converter.py` (the Anthropic↔OpenAI Responses↔Chat Completions converter that mishandled `phase`, multi-text-part rebuild, etc.). The Rust handler from PR-C3 handles `/v1/responses` natively without converting between shapes. After this PR lands, no Python code is on the `/v1/responses` request path.
|
||||
|
||||
### Files
|
||||
|
||||
**Delete:**
|
||||
- `headroom/proxy/responses_converter.py`
|
||||
|
||||
**Modify:**
|
||||
- `headroom/proxy/handlers/openai.py` — remove imports of `responses_converter`. The compression dispatch path that called the converter to convert Responses items to Chat-Completions messages for compression is gone; Rust handles compression natively.
|
||||
- `tests/test_responses_converter*.py` — delete all (Rust tests at `crates/headroom-proxy/tests/integration_responses.rs` cover the surface).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `pytest -x` green.
|
||||
- `git grep responses_converter headroom/` returns nothing.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C3, PR-C4.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H1.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Python converter returns; Rust handler stays in place; both run side-by-side temporarily — but the Rust path is canonical.
|
||||
|
||||
---
|
||||
|
||||
## Phase C acceptance summary
|
||||
|
||||
After all 5 PRs land:
|
||||
|
||||
- ✅ Byte-level SSE parser with full state machine (handles UTF-8 split, ping, [DONE], all delta types, mid-stream errors)
|
||||
- ✅ `/v1/chat/completions` handled in Rust
|
||||
- ✅ `/v1/responses` HTTP handled in Rust
|
||||
- ✅ `/v1/responses` streaming handled in Rust (out-of-order items, all event types)
|
||||
- ✅ Conversations API awareness (warns + skips compression)
|
||||
- ✅ All Responses item types (V4A, local_shell, phase, compaction, MCP, computer, image_gen, etc.) preserved byte-equal
|
||||
- ✅ `responses_converter.py` deleted
|
||||
|
||||
**Phase C retires P1-8 through P1-12, P1-14 through P1-17, P4-40, P4-42 through P4-44, P4-47, P4-48, P0-7 (final), P5-51.**
|
||||
@@ -0,0 +1,222 @@
|
||||
# Phase D — Bedrock & Vertex Native Envelopes
|
||||
|
||||
**Goal:** Replace the fake LiteLLM-based Bedrock/Vertex paths (which lossy-convert Anthropic↔OpenAI shapes) with native handlers in the Rust proxy. After Phase D, Anthropic-on-Bedrock and Anthropic-on-Vertex preserve `thinking`, `redacted_thinking`, `document`, `search_result`, `image`, `server_tool_use`, `mcp_tool_use` blocks AND benefit from the live-zone compression engine.
|
||||
|
||||
**Calendar:** 2 weeks. SigV4 + EventStream are the bulk of the work.
|
||||
|
||||
**Shape:** 4 PRs. D1+D2+D3 are AWS Bedrock; D4 is GCP Vertex.
|
||||
|
||||
---
|
||||
|
||||
## PR-D1 — Native Bedrock InvokeModel route (non-streaming)
|
||||
|
||||
**Branch:** `realign-D1-bedrock-native-invoke`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-D1-bedrock-native-invoke`
|
||||
**Risk:** **HIGH** (new auth + envelope surface)
|
||||
**LOC:** +1500
|
||||
|
||||
### Scope
|
||||
Eliminate part of P4-37 and P4-39. Add `POST /model/{model}/invoke` route to the Rust proxy. Recognizes the Bedrock envelope (`anthropic_version` body field, no `model` field, AWS SigV4 auth). Forwards the (possibly compressed) request to the Bedrock endpoint with re-signed SigV4. Live-zone compression runs the same as for direct Anthropic.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/bedrock/mod.rs` — module re-exports.
|
||||
- `crates/headroom-proxy/src/bedrock/sigv4.rs` — AWS SigV4 signing. Use the `aws-sigv4` crate. Sign over the (possibly modified) request body bytes. Critical: sign **after** Headroom finishes mutating the body, so the signature matches what Bedrock receives.
|
||||
- `crates/headroom-proxy/src/bedrock/invoke.rs` — POST handler for `/model/{model}/invoke`. Detects `anthropic.claude-*` model IDs; routes to live-zone compression for Anthropic shape; signs and forwards.
|
||||
- `crates/headroom-proxy/src/bedrock/envelope.rs` — `BedrockEnvelope` struct: parses `{"anthropic_version": "...", ...rest_of_anthropic_body}`. Re-emits in Bedrock shape with `anthropic_version` preserved as the first key.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/lib.rs` — route `/model/{model}/invoke` and `/model/{model}/converse` (POST) to the new handler.
|
||||
- `crates/headroom-proxy/src/config.rs` — add `--bedrock-region` flag (default `us-east-1`) and AWS credential config (uses `aws-config` crate's default chain).
|
||||
- `Cargo.toml` workspace — add `aws-sigv4`, `aws-config`, `aws-credential-types`.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::native_envelope_round_trip_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::sigv4_signed_correctly_after_compression`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::thinking_block_preserved_through_bedrock`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::redacted_thinking_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::document_block_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::tool_result_array_with_image_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::stop_sequence_null_only_when_present`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::tool_use_input_byte_equal_preserves_key_order`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Manual test against a real Bedrock endpoint (developer's AWS account) succeeds.
|
||||
- Existing fake Bedrock path (`headroom/backends/litellm.py`) still works in Python; this PR adds the Rust path alongside.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C1.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-D2, PR-D3, PR-H2.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Bedrock requests fall back to Python LiteLLM converter (the fake path). No regression for users who weren't using Rust Bedrock.
|
||||
|
||||
### Notes
|
||||
|
||||
- The SigV4 signing scope: `host`, `x-amz-date`, `x-amz-content-sha256` headers + canonical request body. Compute the body hash AFTER any compression mutations.
|
||||
- `accept-encoding` is preserved end-to-end (PAYG/OAuth/subscription all preserve it for Bedrock — there's no legacy CLI to mimic; the Bedrock SDK negotiates compression natively).
|
||||
|
||||
---
|
||||
|
||||
## PR-D2 — Bedrock streaming via binary EventStream
|
||||
|
||||
**Branch:** `realign-D2-bedrock-event-stream`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-D2-bedrock-event-stream`
|
||||
**Risk:** **HIGH** (binary protocol, not SSE)
|
||||
**LOC:** +1100
|
||||
|
||||
### Scope
|
||||
Add `POST /model/{model}/invoke-with-response-stream` route. Bedrock's streaming uses **binary EventStream** (vnd.amazon.eventstream content type), not SSE. Build a parser/forwarder for it. Translate to Anthropic SSE for Anthropic-shape responses (so the existing `AnthropicStreamState` from PR-C1 can run telemetry).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/bedrock/eventstream.rs` — EventStream binary parser. Format: 12-byte prelude (length + headers length + CRC32 of prelude), N bytes of headers, payload, 4-byte CRC32 of message. Parse incrementally; yield `EventStreamMessage { headers: HashMap, payload: Bytes }`.
|
||||
- `crates/headroom-proxy/src/bedrock/eventstream_to_sse.rs` — for Anthropic-shape Bedrock responses, each `EventStreamMessage` whose `:event-type` header is `chunk` carries an Anthropic SSE event in its payload. Re-emit as SSE to the client. (Or pass through as EventStream — choose based on the `Accept` header from the client.)
|
||||
- `crates/headroom-proxy/src/bedrock/invoke_streaming.rs` — POST handler.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/lib.rs` — route `/model/{model}/invoke-with-response-stream`.
|
||||
- `crates/headroom-proxy/src/sse/anthropic.rs` — accept events from EventStream-translated source.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::eventstream_parses_correctly`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::eventstream_translated_to_sse`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::usage_extracted_from_translated_stream`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::client_can_choose_eventstream_or_sse`
|
||||
- Property test: `proptest! { fn eventstream_parser_no_panic(bytes in any::<Vec<u8>>()) { let _ = parse(bytes); } }`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All tests pass.
|
||||
- Manual test against real Bedrock streaming endpoint succeeds.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-D1.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H2.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Streaming Bedrock falls back to Python LiteLLM.
|
||||
|
||||
---
|
||||
|
||||
## PR-D3 — Bedrock-side observability + auth-mode integration
|
||||
|
||||
**Branch:** `realign-D3-bedrock-observability`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-D3-bedrock-observability`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +400
|
||||
|
||||
### Scope
|
||||
Per-Bedrock-model metrics, region tagging, IAM role attribution, and integration with auth-mode policy (Bedrock IAM = "oauth" mode by default; passthrough-prefer compression).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/bedrock/invoke.rs` — auth_mode classification: when an inbound request hits `/model/.../invoke`, classify as `AuthMode::OAuth` for compression policy.
|
||||
- `crates/headroom-proxy/src/observability/prometheus.rs` — add `bedrock_invoke_count_total{model, region}`, `bedrock_invoke_latency_seconds`, `bedrock_eventstream_message_count_total`.
|
||||
|
||||
**Add:**
|
||||
- `docs/bedrock.md` — operator docs: how to configure AWS credentials, what models are supported (any `anthropic.claude-*`), what compression behavior to expect (live-zone-only, lossless preferred).
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_authmode.rs::bedrock_classified_as_oauth`
|
||||
- `crates/headroom-proxy/tests/integration_bedrock_authmode.rs::oauth_policy_passthrough_prefer`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Prometheus scrape includes Bedrock metrics.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-D2, PR-F1 (auth-mode helper).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H2.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Loses Bedrock observability; functional path unchanged.
|
||||
|
||||
---
|
||||
|
||||
## PR-D4 — Native Vertex publisher path
|
||||
|
||||
**Branch:** `realign-D4-vertex-native`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-D4-vertex-native`
|
||||
**Risk:** **HIGH** (new auth + envelope surface)
|
||||
**LOC:** +1300
|
||||
|
||||
### Scope
|
||||
Eliminate P4-38, P4-39 (Vertex parts). Add `POST /v1beta1/projects/{project}/locations/{loc}/publishers/anthropic/models/{model}:rawPredict` and `:streamRawPredict` routes. Vertex auth is GCP ADC (Application Default Credentials) → bearer token. Envelope: `anthropic_version` body field, no `model` field, GCP auth header.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/vertex/mod.rs` — module re-exports.
|
||||
- `crates/headroom-proxy/src/vertex/adc.rs` — GCP ADC bearer token resolution. Use `gcp_auth` crate.
|
||||
- `crates/headroom-proxy/src/vertex/raw_predict.rs` — POST handler.
|
||||
- `crates/headroom-proxy/src/vertex/stream_raw_predict.rs` — streaming handler. Vertex uses SSE for streaming (unlike Bedrock); the existing `AnthropicStreamState` from PR-C1 works directly.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/lib.rs` — route Vertex paths.
|
||||
- `Cargo.toml` workspace — add `gcp_auth`.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::native_envelope_round_trip_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::adc_bearer_token_signed_correctly`
|
||||
- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::thinking_block_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::stream_raw_predict_sse_handled`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All tests pass.
|
||||
- Manual test against a real Vertex endpoint succeeds.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C1, PR-D1 (envelope pattern).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H2.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Vertex requests fall back to LiteLLM Python. No regression for non-Rust-Vertex users.
|
||||
|
||||
---
|
||||
|
||||
## Phase D acceptance summary
|
||||
|
||||
After all 4 PRs land:
|
||||
|
||||
- ✅ Native Bedrock `/model/{model}/invoke` route in Rust
|
||||
- ✅ Native Bedrock `/model/{model}/invoke-with-response-stream` (binary EventStream parsed and translated)
|
||||
- ✅ SigV4 signing post-compression
|
||||
- ✅ All Anthropic block types preserved through Bedrock (thinking, redacted_thinking, document, search_result, image, server_tool_use, mcp_tool_use)
|
||||
- ✅ `stop_sequence: null` no longer hardcoded
|
||||
- ✅ `tool_calls.function.arguments` preserved as string
|
||||
- ✅ Native Vertex `:rawPredict` and `:streamRawPredict` routes
|
||||
- ✅ ADC bearer token resolution
|
||||
- ✅ Bedrock/Vertex classified as `AuthMode::OAuth` (passthrough-prefer compression)
|
||||
- ✅ Per-Bedrock-model and per-Vertex-model Prometheus metrics
|
||||
|
||||
**Phase D retires P4-37, P4-38, P4-39, P4-43.** Marketplace BYOC pitch (per project memory) becomes real.
|
||||
|
||||
After Phase D, the LiteLLM Python converter is no longer on the request path for Bedrock/Vertex — Phase H deletes it.
|
||||
@@ -0,0 +1,338 @@
|
||||
# Phase E — Phase 3 Cache Stabilization
|
||||
|
||||
**Goal:** Add the cache-stabilization surface that today is **completely missing**: tool array deterministic sort, recursive JSON Schema key sort, auto `cache_control` placement (Anthropic), `prompt_cache_key` auto-injection (OpenAI), volatile-content detector with customer warning (no rewrite), cache-bust drift telemetry. These are guide §8.5, §9.11, §6.2, §4.17 implementations and the "Phase 3" of the guide's implementation checklist.
|
||||
|
||||
**Calendar:** 1 week.
|
||||
|
||||
**Shape:** 6 PRs. Mostly parallel; E3 + E4 should land paired (per-mode policy).
|
||||
|
||||
---
|
||||
|
||||
## PR-E1 — Tool array deterministic sort (Rust)
|
||||
|
||||
**Branch:** `realign-E1-tool-array-sort`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-E1-tool-array-sort`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +200
|
||||
|
||||
### Scope
|
||||
Eliminate P3-28. Sort `tools[]` alphabetically by name on the way out. Idempotent: re-sorting an already-sorted array is a no-op. Implementation matches Python's existing `_sort_tools_deterministically` (`headroom/proxy/handlers/anthropic.py:34-58`) — same sort key, same output bytes (modulo serialization).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/compression/tool_def_normalize.rs`:
|
||||
```rust
|
||||
pub fn sort_tools_deterministically(tools: &mut Vec<&RawValue>) -> Result<()> {
|
||||
// Sort key: tool["name"] string, fallback to MD5(serialized) for unnamed tools.
|
||||
tools.sort_by_key(|t| {
|
||||
let parsed: serde_json::Value = serde_json::from_str(t.get()).unwrap_or_default();
|
||||
parsed.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — call `sort_tools_deterministically` on the request body's `tools` array before forwarding (only on PAYG; gated by Phase F PR-F2).
|
||||
- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — same.
|
||||
- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — same.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_tool_sort.rs::sort_alphabetic_by_name`
|
||||
- `crates/headroom-proxy/tests/integration_tool_sort.rs::idempotent_resort_no_change`
|
||||
- `crates/headroom-proxy/tests/integration_tool_sort.rs::byte_stable_across_runs`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Output `tools[]` byte-equal between Rust and Python sort implementations.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B2.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-E2.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Rust path matches client's tool order (subject to the cache-bust risk).
|
||||
|
||||
---
|
||||
|
||||
## PR-E2 — Recursive JSON Schema key sort
|
||||
|
||||
**Branch:** `realign-E2-schema-key-sort`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-E2-schema-key-sort`
|
||||
**Risk:** **MEDIUM** (recursive sort over tool schemas; risk of breaking schema semantics if there's an `if`/`then`/`else` or `oneOf` ordering invariant — there isn't, but verify)
|
||||
**LOC:** +300
|
||||
|
||||
### Scope
|
||||
Eliminate P3-29. Recursively sort JSON Schema object keys in every tool's `input_schema`. This includes nested `properties`, `definitions`, `oneOf`, `anyOf`, `allOf`, `if/then/else`, `additionalProperties`, etc.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/tool_def_normalize.rs` — add `sort_schema_keys_recursive`. Walks every Object node; replaces with `IndexMap` rebuilt in alphabetic key order. Preserves Array order (JSON Schema arrays are ordered: `prefixItems`, `oneOf` alternatives, etc.).
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_schema_sort.rs::flat_schema_keys_sorted`
|
||||
- `crates/headroom-proxy/tests/integration_schema_sort.rs::nested_properties_sorted`
|
||||
- `crates/headroom-proxy/tests/integration_schema_sort.rs::oneof_array_order_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_schema_sort.rs::definitions_keys_sorted`
|
||||
- `crates/headroom-proxy/tests/integration_schema_sort.rs::idempotent_resort`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Snapshot test on a real production tool schema (e.g., Claude Code's `Read` tool) — pin the sorted bytes.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-E1.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-E5.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Schema keys reflect customer ordering.
|
||||
|
||||
---
|
||||
|
||||
## PR-E3 — Auto `cache_control` breakpoint placement (Anthropic)
|
||||
|
||||
**Branch:** `realign-E3-cache-control-auto-place`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-E3-cache-control-auto-place`
|
||||
**Risk:** **MEDIUM-HIGH** (auto-adds bytes to client request — only on PAYG)
|
||||
**LOC:** +400
|
||||
|
||||
### Scope
|
||||
Eliminate P3-31. When PAYG mode is detected (Phase F PR-F1) and the customer has not set any `cache_control` markers, auto-place up to 4 ephemeral markers at:
|
||||
1. End of system prompt (1 marker)
|
||||
2. End of `tools[]` (1 marker)
|
||||
3. After the last stable conversation history boundary (1 marker; configurable threshold for "stable")
|
||||
4. Before the latest user message (1 marker)
|
||||
|
||||
OAuth and subscription modes: never auto-place (could void scope).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/compression/cache_control.rs` — `pub fn auto_place_breakpoints(body: &mut serde_json::Value, auth_mode: AuthMode)`. Walks the structure; appends `cache_control: {type: "ephemeral"}` to the trailing block of system, tools, history, and current user message.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — call `auto_place_breakpoints` on PAYG only.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::payg_auto_places_4_markers`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::oauth_no_auto_placement`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::subscription_no_auto_placement`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::customer_set_markers_respected_no_addition`
|
||||
- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::ttl_ordering_correct_1h_before_5m`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Customer requests with existing markers are unmodified.
|
||||
- A representative PAYG request gets 4 markers in the right positions.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A4, PR-F1, PR-F2.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Customers without their own `cache_control` markers don't get auto-placement; cache benefit smaller but correct.
|
||||
|
||||
---
|
||||
|
||||
## PR-E4 — `prompt_cache_key` auto-injection (OpenAI)
|
||||
|
||||
**Branch:** `realign-E4-prompt-cache-key-inject`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-E4-prompt-cache-key-inject`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** +250
|
||||
|
||||
### Scope
|
||||
Eliminate P3-30. For OpenAI Chat Completions and Responses requests on PAYG mode where the customer has not set `prompt_cache_key`, auto-inject one derived from a stable session hash. OAuth/subscription modes: never inject (the CLI may already populate this and Headroom must preserve byte-for-byte).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — add `inject_prompt_cache_key` step.
|
||||
- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — same.
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/session.rs` — `pub fn derive_prompt_cache_key(session_id: &str, model: &str) -> String` — returns `{session_id}_{model_family}` (deterministic per session+model).
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::payg_auto_injects_when_absent`
|
||||
- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::customer_value_preserved`
|
||||
- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::oauth_no_injection`
|
||||
- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::subscription_no_injection`
|
||||
- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::same_session_same_key_deterministic`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-F1, PR-F2.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. OpenAI cache routing less sticky on PAYG; functional.
|
||||
|
||||
---
|
||||
|
||||
## PR-E5 — Volatile-content detector with customer warning (no rewrite)
|
||||
|
||||
**Branch:** `realign-E5-volatile-detector`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-E5-volatile-detector`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +400
|
||||
|
||||
### Scope
|
||||
Eliminate P3-32. Detect dynamic content in the early prompt (timestamps, UUIDs, JWT tokens, build hashes, randomized IDs) and surface to the customer via a log line and a Prometheus metric. **Do not rewrite.** This is what Python's `cache_aligner.py` was *trying* to do correctly (the rewrite path is gone in Phase A PR-A2).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-core/src/transforms/volatile_detector.rs`:
|
||||
```rust
|
||||
pub struct VolatileDetector { /* compiled regex set */ }
|
||||
|
||||
impl VolatileDetector {
|
||||
pub fn scan(&self, content: &str) -> Vec<VolatileFinding>;
|
||||
}
|
||||
|
||||
pub struct VolatileFinding {
|
||||
pub kind: VolatileKind, // Timestamp | Uuid | Jwt | BuildHash | ...
|
||||
pub byte_offset: usize,
|
||||
pub matched: String,
|
||||
pub recommendation: String, // "Move to metadata"
|
||||
}
|
||||
```
|
||||
Patterns:
|
||||
- ISO 8601 timestamp regex
|
||||
- UUID v4 regex
|
||||
- JWT shape (`eyJ...`)
|
||||
- Long hex strings >=32 chars (build hashes)
|
||||
- Unix epoch timestamps within an order of magnitude of `now`
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — run scanner on system prompt; if findings, log warning and increment metric.
|
||||
- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — same on `instructions` field.
|
||||
- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — same.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-core/tests/volatile_detector.rs::detects_iso_8601_timestamp`
|
||||
- `crates/headroom-core/tests/volatile_detector.rs::detects_uuid_v4`
|
||||
- `crates/headroom-core/tests/volatile_detector.rs::detects_jwt_shape`
|
||||
- `crates/headroom-core/tests/volatile_detector.rs::detects_build_hash`
|
||||
- `crates/headroom-core/tests/volatile_detector.rs::no_false_positives_on_normal_prose`
|
||||
- `crates/headroom-proxy/tests/integration_volatile.rs::warning_logged_on_volatile_system_prompt`
|
||||
- `crates/headroom-proxy/tests/integration_volatile.rs::system_prompt_bytes_unchanged`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Detector runs in <1ms on a 4KB system prompt (don't slow the request path).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-E2.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Detector loses; no functional regression.
|
||||
|
||||
---
|
||||
|
||||
## PR-E6 — Cache-bust drift detector telemetry
|
||||
|
||||
**Branch:** `realign-E6-cache-bust-detector`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-E6-cache-bust-detector`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +500
|
||||
|
||||
### Scope
|
||||
Eliminate P3-35. Hash the prefix (system + tools + first N stable messages) of every request. Track per-session prefix hashes. When the prefix hash changes between turns, increment a counter and log a `prefix_drift` event with which subsystem mutated (likely none after Phase A — but if it happens, we want to know).
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/src/observability/prefix_drift.rs`:
|
||||
```rust
|
||||
pub struct PrefixDriftDetector {
|
||||
// Keyed by session_id; stores last-seen prefix hash + timestamp.
|
||||
cache: Cache<SessionId, (PrefixHash, Instant)>,
|
||||
}
|
||||
|
||||
impl PrefixDriftDetector {
|
||||
pub fn check(&self, session_id: &str, body: &serde_json::Value) -> DriftCheck;
|
||||
}
|
||||
|
||||
pub enum DriftCheck {
|
||||
FirstSeen,
|
||||
Stable,
|
||||
Drifted { previous_hash: PrefixHash, new_hash: PrefixHash, age: Duration },
|
||||
}
|
||||
```
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/observability/prometheus.rs` — add `prefix_drift_detected_total{provider, model}` counter.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_prefix_drift.rs::stable_prefix_no_drift`
|
||||
- `crates/headroom-proxy/tests/integration_prefix_drift.rs::system_change_detected_as_drift`
|
||||
- `crates/headroom-proxy/tests/integration_prefix_drift.rs::tools_reorder_detected`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- A canary request that mutates the system prompt mid-session triggers the counter.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B2.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Loses observability; no functional regression.
|
||||
|
||||
---
|
||||
|
||||
## Phase E acceptance summary
|
||||
|
||||
After all 6 PRs land:
|
||||
|
||||
- ✅ Tools alphabetically sorted (deterministic, idempotent)
|
||||
- ✅ JSON Schema keys recursively sorted
|
||||
- ✅ `cache_control` auto-placement on PAYG (4 markers)
|
||||
- ✅ `prompt_cache_key` auto-injection on PAYG (OpenAI)
|
||||
- ✅ Volatile-content detector + customer warning (no rewrite)
|
||||
- ✅ Cache-bust drift telemetry per session
|
||||
|
||||
**Phase E retires P3-28 through P3-32, P3-35.**
|
||||
@@ -0,0 +1,254 @@
|
||||
# Phase F — Auth-Mode Policy Gates
|
||||
|
||||
**Goal:** Make auth mode a first-class policy axis. Detect (PAYG / OAuth / subscription) at request entry; gate compression behavior, header injection, and TOIN aggregation per mode. Stealth mode for subscription CLIs.
|
||||
|
||||
**Calendar:** 1 week.
|
||||
|
||||
**Shape:** 4 PRs. F1 first; F2 + F3 + F4 parallel after.
|
||||
|
||||
Reference: `~/.claude/projects/-Users-tchopra-claude-projects-headroom/memory/project_auth_mode_compression_nuances.md`.
|
||||
|
||||
---
|
||||
|
||||
## PR-F1 — `classify_auth_mode` helper
|
||||
|
||||
**Branch:** `realign-F1-classify-auth-mode`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-F1-classify-auth-mode`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +500
|
||||
|
||||
### Scope
|
||||
Single helper called at request entry returning `AuthMode = Payg | OAuth | Subscription`. Pure-function classification from headers (Authorization shape, OpenAI-Beta, anthropic-beta) and User-Agent prefix.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-core/src/auth_mode.rs`:
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AuthMode { Payg, OAuth, Subscription }
|
||||
|
||||
pub fn classify(headers: &http::HeaderMap) -> AuthMode {
|
||||
let ua = headers.get("user-agent").and_then(|h| h.to_str().ok()).unwrap_or("").to_lowercase();
|
||||
const SUBSCRIPTION_UA_PREFIXES: &[&str] = &[
|
||||
"claude-cli/", "claude-code/", "codex-cli/", "cursor/",
|
||||
"claude-vscode/", "github-copilot/", "anthropic-cli/", "antigravity/",
|
||||
];
|
||||
if SUBSCRIPTION_UA_PREFIXES.iter().any(|p| ua.contains(p)) {
|
||||
return AuthMode::Subscription;
|
||||
}
|
||||
|
||||
let auth = headers.get("authorization").and_then(|h| h.to_str().ok()).unwrap_or("");
|
||||
if auth.starts_with("Bearer ") {
|
||||
let token = &auth[7..];
|
||||
if token.starts_with("sk-ant-api") || token.starts_with("sk-") {
|
||||
return AuthMode::Payg;
|
||||
}
|
||||
if token.starts_with("sk-ant-oat-") || token.split('.').count() >= 3 {
|
||||
// sk-ant-oat-* (Claude Pro OAuth) or JWT (Codex/Cursor OAuth)
|
||||
return AuthMode::OAuth;
|
||||
}
|
||||
}
|
||||
|
||||
// Bedrock / Vertex (no Authorization header from client; signed downstream)
|
||||
if !auth.is_empty() == false
|
||||
&& headers.get("x-api-key").is_none()
|
||||
&& headers.get("x-goog-api-key").is_none()
|
||||
{
|
||||
return AuthMode::OAuth;
|
||||
}
|
||||
|
||||
// x-api-key present (Anthropic API key style)
|
||||
if headers.contains_key("x-api-key") {
|
||||
return AuthMode::Payg;
|
||||
}
|
||||
|
||||
AuthMode::Payg // default
|
||||
}
|
||||
```
|
||||
|
||||
**Add (Python):**
|
||||
- `headroom/proxy/auth_mode.py` — Python port of the same logic. Used in Python paths until Phase H deletes them.
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-core/src/lib.rs` — `pub mod auth_mode;`.
|
||||
- `crates/headroom-proxy/src/proxy.rs` — call `classify` at request entry; store in request extensions for downstream handlers.
|
||||
- `headroom/proxy/handlers/anthropic.py` — call Python `classify_auth_mode(headers)` at request entry.
|
||||
- `headroom/proxy/handlers/openai.py` — same.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-core/tests/auth_mode.rs::api_key_classified_payg`
|
||||
- `crates/headroom-core/tests/auth_mode.rs::oauth_jwt_classified_oauth`
|
||||
- `crates/headroom-core/tests/auth_mode.rs::oauth_sk_ant_oat_classified_oauth`
|
||||
- `crates/headroom-core/tests/auth_mode.rs::claude_code_ua_classified_subscription`
|
||||
- `crates/headroom-core/tests/auth_mode.rs::cursor_ua_classified_subscription`
|
||||
- `crates/headroom-core/tests/auth_mode.rs::no_auth_no_user_agent_default_payg`
|
||||
- `crates/headroom-core/tests/auth_mode.rs::bedrock_no_auth_classified_oauth`
|
||||
- Python equivalents in `tests/test_auth_mode.py`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Detection runs in <10us per call.
|
||||
- Documented in `docs/auth-modes.md` with the detection rules and how to extend.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-F2, PR-F3, PR-F4.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. All requests treated as PAYG (current behavior).
|
||||
|
||||
---
|
||||
|
||||
## PR-F2 — Per-mode compression policy gates
|
||||
|
||||
**Branch:** `realign-F2-per-mode-policy`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-F2-per-mode-policy`
|
||||
**Risk:** **MEDIUM-HIGH** (changes compression behavior per request)
|
||||
**LOC:** +600
|
||||
|
||||
### Scope
|
||||
Wire `AuthMode` into every compression decision per the policy matrix in `02-architecture.md §2.4`. PAYG = aggressive (current default). OAuth = passthrough-prefer (no auto-`cache_control`, no auto-`prompt_cache_key`, no lossy compressors). Subscription = stealth (everything OAuth does + preserve `accept-encoding`, never strip; never inject `X-Headroom-*`; never mutate User-Agent).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — gate `auto_place_breakpoints` on `auth_mode == Payg`.
|
||||
- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — gate `inject_prompt_cache_key` on `auth_mode == Payg`.
|
||||
- `crates/headroom-proxy/src/compression/live_zone.rs` — gate lossy compressors (Kompress text) on `auth_mode == Payg`. OAuth and Subscription get lossless-only compression.
|
||||
- `crates/headroom-proxy/src/headers.rs:103-117` — `add_x_forwarded_headers` becomes `add_x_forwarded_headers_if(auth_mode)`. Skip on Subscription.
|
||||
- `crates/headroom-proxy/src/proxy.rs` — `accept-encoding` strip becomes conditional on `auth_mode != Subscription`.
|
||||
- `headroom/proxy/handlers/anthropic.py` — gate Python compression decisions identically.
|
||||
- `headroom/proxy/handlers/openai.py` — same.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::payg_aggressive_compression`
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::oauth_no_auto_cache_control`
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::oauth_no_auto_prompt_cache_key`
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::oauth_lossless_only`
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::subscription_no_x_forwarded`
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::subscription_preserves_accept_encoding`
|
||||
- `crates/headroom-proxy/tests/integration_authmode_policy.rs::subscription_lossless_only`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Manual smoke test: a real Claude Code session through the proxy produces no `X-Forwarded-*` upstream and preserves `accept-encoding`.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-F1, PR-E3, PR-E4.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. All requests treated as PAYG. No functional regression for PAYG users; OAuth/Subscription users may see scope-rejection or revocation increase.
|
||||
|
||||
---
|
||||
|
||||
## PR-F3 — TOIN per-tenant aggregation key
|
||||
|
||||
**Branch:** `realign-F3-toin-per-tenant`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-F3-toin-per-tenant`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** -200 / +400
|
||||
|
||||
### Scope
|
||||
Eliminate P5-56. Extend TOIN's aggregation key from `structure_hash` to `(auth_mode, model_family, structure_hash)`. Storage key prefix updated; in-memory dicts re-keyed. Existing observations from before this PR are preserved under a `legacy/` prefix and deprecated over the next 30 days.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/telemetry/toin.py:103` — `Pattern` adds `auth_mode: str = "unknown"`, `model_family: str = "unknown"`. (Already covered by Phase B PR-B5; this PR ensures the wiring lands.)
|
||||
- `headroom/telemetry/toin.py:477, 496, 727, 729, 1248, 1256` — change aggregation key to tuple.
|
||||
- `headroom/telemetry/toin.py` — migration helper that walks the legacy `structure_hash`-only store and copies entries under `("unknown", "unknown", structure_hash)` for graceful degrade.
|
||||
- `headroom/telemetry/toin.py` — bumping aggregation key invalidates earlier recommendations; re-publish via the deploy CLI.
|
||||
- `headroom/subscription/tracker.py:166` — replace `_current_token: str` (raw OAuth bearer storage) with `_current_token_id: str` (a one-way hash + last-4 chars for debugging). Polling code adapts to use the actual `Authorization` header per request rather than the stored copy.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_toin_per_tenant.py::test_aggregation_key_includes_auth_mode_model`
|
||||
- `tests/test_toin_per_tenant.py::test_legacy_observations_preserved_under_unknown`
|
||||
- `tests/test_toin_per_tenant.py::test_publish_per_auth_mode_writes_separate_recommendations`
|
||||
- `tests/test_subscription_tracker_token_hardening.py::test_raw_token_not_stored_in_memory`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Recommendations file becomes structured `recommendations.toml` with sections per `(auth_mode, model_family)`.
|
||||
- The subscription tracker token-leak risk closed.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B5, PR-F1.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. TOIN reverts to global aggregation. No functional break.
|
||||
|
||||
---
|
||||
|
||||
## PR-F4 — `X-Forwarded-*` conditional in Rust path
|
||||
|
||||
**Branch:** `realign-F4-x-forwarded-conditional`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-F4-x-forwarded-conditional`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +100
|
||||
|
||||
### Scope
|
||||
Eliminate P5-53. The Rust proxy currently always adds `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-Id` to upstream-bound requests. Make this conditional: PAYG → add; OAuth → add; Subscription → skip (fingerprint risk).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/headers.rs:103-117` — `add_x_forwarded_headers` takes an `AuthMode` parameter; no-ops on Subscription.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_x_forwarded_authmode.rs::payg_adds_xfwd`
|
||||
- `crates/headroom-proxy/tests/integration_x_forwarded_authmode.rs::oauth_adds_xfwd`
|
||||
- `crates/headroom-proxy/tests/integration_x_forwarded_authmode.rs::subscription_no_xfwd`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-F1.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Headers always added. Mild fingerprint regression for Subscription users.
|
||||
|
||||
---
|
||||
|
||||
## Phase F acceptance summary
|
||||
|
||||
After all 4 PRs land:
|
||||
|
||||
- ✅ `classify_auth_mode` helper detects PAYG / OAuth / Subscription
|
||||
- ✅ Per-mode compression policy gates (auto-cache_control, prompt_cache_key, lossy compressors)
|
||||
- ✅ TOIN aggregation key per `(auth_mode, model_family, structure_hash)`
|
||||
- ✅ Subscription tracker doesn't store raw OAuth bearer
|
||||
- ✅ `X-Forwarded-*` skipped on Subscription mode
|
||||
- ✅ `accept-encoding` preserved on Subscription mode
|
||||
|
||||
**Phase F retires P5-52, P5-53, P5-54, P5-55, P5-56.**
|
||||
|
||||
After Phase F, fingerprint risk for Subscription CLI users is dramatically reduced.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Phase G — RTK Breadth + Observability
|
||||
|
||||
**Goal:** Extend RTK coverage to more wrap-CLI agents; close the dead `tokens_saved_rtk` data plane; add per-invocation RTK metrics; add the cache-hit-rate, compression-ratio, token-validation observability surface that's missing today.
|
||||
|
||||
**Calendar:** 1 week.
|
||||
|
||||
**Shape:** 3 PRs.
|
||||
|
||||
**Decision context:** Per Agent F audit and 2026-05-01 user direction, **RTK stays on the wrap-CLI side, NOT the proxy side**. Proxy-side invocation is rejected because (a) cache hot zone risk on tool_result content compression, (b) parallel implementation with `crates/headroom-core/src/transforms/log_compressor.rs`, (c) RTK rewrites *commands* not *outputs* — different value proposition. "Integrate RTK with everything" reads as "extend wrap-CLI breadth + close the data plane + observability."
|
||||
|
||||
---
|
||||
|
||||
## PR-G1 — Wrap CLI breadth: cline, continue, goose, openhands
|
||||
|
||||
**Branch:** `realign-G1-wrap-more-agents`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-G1-wrap-more-agents`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +800
|
||||
|
||||
### Scope
|
||||
Eliminate P5-62. Add `headroom wrap cline`, `headroom wrap continue`, `headroom wrap goose`, `headroom wrap openhands` (extending the existing pattern from `wrap claude` / `wrap codex` / `wrap aider` / `wrap copilot` / `wrap cursor`). Each wrap subcommand:
|
||||
1. Ensures the RTK binary is installed (`_ensure_rtk_binary()`).
|
||||
2. Injects the `<!-- headroom:rtk-instructions -->` block into the agent's instruction file (AGENTS.md / .cursorrules / etc.).
|
||||
3. Spawns the proxy (or attaches to a running one).
|
||||
4. Launches the agent CLI with proxy env-var overrides.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `headroom/cli/wrap/cline.py` — wrap implementation for Cline (agent that lives in VS Code; instruction file is `.clinerules`).
|
||||
- `headroom/cli/wrap/continue_dev.py` — Continue agent (`.continue/config.json` configuration; system message injection).
|
||||
- `headroom/cli/wrap/goose.py` — Goose agent (Block's CLI; `.goose/config.yaml`).
|
||||
- `headroom/cli/wrap/openhands.py` — OpenHands (instruction injection via `OPENHANDS_INSTRUCTIONS` env var).
|
||||
|
||||
**Modify:**
|
||||
- `headroom/cli/wrap/__init__.py` — register new subcommands.
|
||||
- `headroom/cli/main.py` — `headroom wrap --help` lists new agents.
|
||||
- `e2e/wrap/run.py` — extend the e2e runner to exercise the new wrappers (each wrapper has a smoke test that asserts: binary installed, instruction injected, proxy started, dummy LLM call works).
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_cli/test_wrap_cline.py::test_wrap_cline_smoke`
|
||||
- `tests/test_cli/test_wrap_continue.py::test_wrap_continue_smoke`
|
||||
- `tests/test_cli/test_wrap_goose.py::test_wrap_goose_smoke`
|
||||
- `tests/test_cli/test_wrap_openhands.py::test_wrap_openhands_smoke`
|
||||
- `tests/test_cli/test_wrap_idempotent_inject.py::test_double_injection_no_duplicate_block` (for each new wrapper)
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Manual test: `headroom wrap cline -- claude-3-7-sonnet` launches a Cline session with the proxy in-front and RTK instructions in `.clinerules`.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Existing wrappers continue working; new ones absent.
|
||||
|
||||
### Notes
|
||||
|
||||
- **Future agents to add later (not in this PR):** Roo Code, Devin-style CLIs, raw `gh copilot` standalone, gpt-engineer, sweep, smol-developer. Add as separate PRs as adoption justifies.
|
||||
|
||||
---
|
||||
|
||||
## PR-G2 — Wire `tokens_saved_rtk` data plane
|
||||
|
||||
**Branch:** `realign-G2-tokens-saved-rtk`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-G2-tokens-saved-rtk`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +200
|
||||
|
||||
### Scope
|
||||
Eliminate P5-60. The `tokens_saved_rtk` field on `SubscriptionContribution` (`headroom/subscription/models.py:260`) exists but is never populated. Wire it: poll `rtk gain --format json` periodically (already done by `_get_rtk_stats` in `helpers.py:132`), diff the cumulative `tokens_saved` since last snapshot, and feed into `tracker.update_session_savings(tokens_saved_rtk=delta)`.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `headroom/subscription/tracker.py` — add `_last_rtk_tokens_saved: int = 0` state; on every `update_session_savings` call, fetch `_get_rtk_stats()`, compute `delta = current.tokens_saved - self._last_rtk_tokens_saved`, set `tokens_saved_rtk=delta`, update state.
|
||||
- `headroom/proxy/helpers.py:132` — `_get_rtk_stats` returns `RtkStats { invocations: int, tokens_saved: int, last_run_at: datetime }`. Memoization stays at 5s.
|
||||
|
||||
**Tests added:**
|
||||
- `tests/test_subscription_tracker_rtk_wired.py::test_tokens_saved_rtk_populated_from_rtk_stats`
|
||||
- `tests/test_subscription_tracker_rtk_wired.py::test_delta_computed_correctly_across_polls`
|
||||
- `tests/test_subscription_tracker_rtk_wired.py::test_rtk_failure_zero_delta_no_throw`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- A wrap session with RTK invocations produces `tokens_saved_rtk > 0` after the session ends.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. `tokens_saved_rtk` returns to silent zero.
|
||||
|
||||
---
|
||||
|
||||
## PR-G3 — Per-invocation RTK metrics + observability gaps
|
||||
|
||||
**Branch:** `realign-G3-rtk-metrics-and-obs`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-G3-rtk-metrics-and-obs`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +600
|
||||
|
||||
### Scope
|
||||
Eliminate P6-68, P6-69, P5-58, P4-41, P4-42, P4-45, and P5-61 (documentation). Add Prometheus metrics:
|
||||
- `wrap_rtk_invocations_total{tool}` — derived from `rtk gain --format json` polling (`tool` label is the `git`, `ls`, `cargo`, etc. command).
|
||||
- `wrap_rtk_tokens_saved_per_session` — histogram, populated at session end.
|
||||
- `proxy_cache_hit_rate_per_session` — histogram, computed from `usage.cache_read_input_tokens / total_input_tokens` per session.
|
||||
- `proxy_compression_ratio_by_strategy{strategy, content_type}` — histogram.
|
||||
- `proxy_compression_rejected_by_token_check_total{strategy}` — counter (already in PR-B4; ensure it's exported here).
|
||||
- `proxy_passthrough_bytes_modified_total{path}` — gauge that **must stay 0** outside compression-on path. Alarm if non-zero.
|
||||
- `proxy_rate_limit_remaining_*` — extracted from upstream response headers.
|
||||
- `proxy_service_tier_count_total{tier}` — counter for `service_tier` distribution.
|
||||
- `proxy_response_status_count_total{status}` — `incomplete | failed | cancelled | completed | in_progress`.
|
||||
- `proxy_image_generation_call_log_redacted_total` — counter for log redactions of multi-MB base64.
|
||||
|
||||
Plus image base64 log redaction (P4-45) lands here.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-proxy/src/observability/prometheus.rs` — add all new metrics.
|
||||
- `crates/headroom-proxy/src/sse/anthropic.rs` — emit `proxy_cache_hit_rate_per_session` from `usage.cache_read_input_tokens / total_input_tokens` on `message_delta`.
|
||||
- `crates/headroom-proxy/src/sse/openai_responses.rs` — emit on `response.completed`.
|
||||
- `crates/headroom-proxy/src/sse/openai_chat.rs` — emit on final usage chunk.
|
||||
- `crates/headroom-proxy/src/handlers/responses.rs` — extract and log `service_tier`.
|
||||
- `crates/headroom-proxy/src/handlers/responses.rs` — log `incomplete_details.reason` when `status == incomplete`.
|
||||
- `headroom/proxy/request_logger.py` — redact base64 strings >1024 bytes; replace with `<base64 truncated, X bytes>`.
|
||||
- `crates/headroom-proxy/src/observability/cache_hit_rate.rs` — new module.
|
||||
- `crates/headroom-proxy/src/observability/compression_ratio.rs` — new module.
|
||||
|
||||
**Add:**
|
||||
- `docs/observability.md` — documents every metric, what it means, what an operator should do when it drifts.
|
||||
- `docs/rtk-architecture.md` — explicitly documents the decision: RTK is wrap-CLI-only; proxy-side invocation is rejected. Includes the rationale (cache hot zone, parallel-impl with log_compressor, command-rewrite-vs-output-rewrite). Future contributors hit this doc before considering a proxy-side RTK call.
|
||||
|
||||
**Tests added:**
|
||||
- `crates/headroom-proxy/tests/integration_metrics.rs::cache_hit_rate_emitted_per_session`
|
||||
- `crates/headroom-proxy/tests/integration_metrics.rs::compression_ratio_emitted_per_strategy`
|
||||
- `crates/headroom-proxy/tests/integration_metrics.rs::passthrough_bytes_modified_zero_when_no_compression`
|
||||
- `crates/headroom-proxy/tests/integration_metrics.rs::service_tier_logged`
|
||||
- `crates/headroom-proxy/tests/integration_metrics.rs::incomplete_status_logged_with_reason`
|
||||
- `tests/test_image_log_redaction.py::test_large_base64_truncated`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All tests pass.
|
||||
- Manual scrape of `/metrics` shows the new metric families.
|
||||
- `docs/rtk-architecture.md` reviewed and approved.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Loses observability; no functional regression.
|
||||
|
||||
---
|
||||
|
||||
## Phase G acceptance summary
|
||||
|
||||
After all 3 PRs land:
|
||||
|
||||
- ✅ Wrap CLI coverage extends to cline, continue, goose, openhands
|
||||
- ✅ `tokens_saved_rtk` field populated end-to-end
|
||||
- ✅ Per-invocation RTK Prometheus metrics
|
||||
- ✅ Per-session cache-hit-rate metric
|
||||
- ✅ Per-block compression-ratio histogram
|
||||
- ✅ Token-validation rejection counter
|
||||
- ✅ Passthrough-bytes-modified gauge (alarm-able)
|
||||
- ✅ Rate-limit headers observed and exported
|
||||
- ✅ `service_tier` distribution metric
|
||||
- ✅ Response status (`incomplete | failed | cancelled`) logged with reason
|
||||
- ✅ Image base64 log redaction
|
||||
- ✅ `docs/rtk-architecture.md` documents the keep-RTK-on-wrap-side decision
|
||||
|
||||
**Phase G retires P4-41, P4-42, P4-45, P5-58, P5-60, P5-61, P5-62, P6-68, P6-69, P6-72.**
|
||||
@@ -0,0 +1,214 @@
|
||||
# Phase H — Python Proxy Retirement
|
||||
|
||||
**Goal:** With Rust at full parity (Phases A–G), delete the Python proxy server, handlers, transforms, and supporting modules. Keep Python only where it's the right tool: CLI wrappers, RTK installer, evals, learn, memory writers, tokenizers (parity backstop).
|
||||
|
||||
**Calendar:** 2 weeks.
|
||||
|
||||
**Shape:** 3 PRs. H1 retires the request-path Python; H2 retires Bedrock/Vertex backend; H3 cleans up.
|
||||
|
||||
**Pre-requisites:**
|
||||
- Phase A–G complete.
|
||||
- Real-traffic shadow test (Phase I) shows Rust ≥99.9% byte-equality vs Python on representative traffic.
|
||||
- Cache-hit-rate parity with direct upstream confirmed (Phase G observability).
|
||||
- All Bedrock/Vertex paths covered by native Rust handlers (Phase D).
|
||||
|
||||
---
|
||||
|
||||
## PR-H1 — Retire Python proxy request path
|
||||
|
||||
**Branch:** `realign-H1-retire-python-proxy-request-path`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-H1-retire-python-proxy-request-path`
|
||||
**Risk:** **HIGH** (production-facing change; canary deploy mandatory)
|
||||
**LOC:** **-15,000 / +500**
|
||||
|
||||
### Scope
|
||||
Delete the Python FastAPI server, all handlers, the responses converter (already gone in Phase C PR-C5), memory subsystem (replaced by live-zone tail injection from Phase A PR-A2 and Phase B PR-B6), semantic cache, batch handler, etc. The Rust proxy becomes the canonical request-path implementation. Operators run only `headroom-proxy` (Rust binary).
|
||||
|
||||
### Files
|
||||
|
||||
**Delete:**
|
||||
- `headroom/proxy/server.py` (2864 LOC)
|
||||
- `headroom/proxy/handlers/anthropic.py` (2423 LOC)
|
||||
- `headroom/proxy/handlers/openai.py` (2742 LOC)
|
||||
- `headroom/proxy/handlers/streaming.py` (1131 LOC)
|
||||
- `headroom/proxy/handlers/gemini.py` (839 LOC)
|
||||
- `headroom/proxy/handlers/batch.py` (1010 LOC)
|
||||
- `headroom/proxy/responses_converter.py` — already deleted in PR-C5; verify gone.
|
||||
- `headroom/proxy/memory_handler.py` (1756 LOC)
|
||||
- `headroom/proxy/memory_tool_adapter.py` (1273 LOC)
|
||||
- `headroom/proxy/semantic_cache.py` (142 LOC)
|
||||
- `headroom/proxy/savings_tracker.py` (934 LOC) — re-implemented in Rust as part of `observability/`.
|
||||
- `headroom/proxy/loopback_guard.py` (92 LOC) — Rust equivalent in `crates/headroom-proxy/src/handlers/debug.rs`.
|
||||
- `headroom/proxy/ws_session_registry.py` (226 LOC) — Rust equivalent.
|
||||
- `headroom/proxy/interceptors/` — all of this directory.
|
||||
- `headroom/proxy/cost.py`, `helpers.py`, `rate_limiter.py`, `request_logger.py` — re-implemented in Rust as part of compression dispatch / observability.
|
||||
- `headroom/proxy/prometheus_metrics.py` — re-implemented in Rust as `observability/prometheus.rs`.
|
||||
- `headroom/proxy/extensions.py`, `models.py`, `modes.py`, `stage_timer.py`, `warmup.py`, `responses_converter.py`, `debug_introspection.py`.
|
||||
- `headroom/transforms/cache_aligner.py` — already gutted in Phase A PR-A2; delete the remaining stub.
|
||||
|
||||
**Move:**
|
||||
- `headroom/proxy/loopback_guard.py` test logic → `crates/headroom-proxy/tests/integration_loopback_guard.rs`.
|
||||
|
||||
**Modify:**
|
||||
- `headroom/cli/proxy.py` — `headroom proxy start` now spawns the Rust binary (`./target/release/headroom-proxy`) instead of `uvicorn headroom.proxy.server:app`.
|
||||
- `headroom/cli/wrap/*.py` — same: env-var setup remains, but `proxy_url` points at the Rust binary's listen address.
|
||||
- `pyproject.toml` — remove `fastapi`, `uvicorn`, `pydantic`, etc. from runtime deps; keep them in dev/test deps for parity harness only.
|
||||
- `Dockerfile` — drop the Python proxy server stage; the Rust binary is the only proxy.
|
||||
- `docker-compose.yml` — same.
|
||||
- `RUST_DEV.md` — promote the Rust proxy from "Phase 1 transparent reverse proxy" to "the proxy."
|
||||
- All operator runbooks in `wiki/` and `docs/` — update to reference Rust binary.
|
||||
|
||||
**Tests deleted:**
|
||||
- `tests/test_proxy_*.py` — most of these (which test the Python proxy directly). Keep tests that exercise CLI wrappers, RTK, evals, learn, memory writers, tokenizers.
|
||||
- Roughly 150 test files; keep ~40 that don't depend on the Python proxy.
|
||||
|
||||
**Tests added:**
|
||||
- `e2e/proxy_full/test_e2e_canary.py` — deploys the Rust binary in a container; runs a full conversation suite; asserts cache hit rate, compression value, no 5xx errors. Run pre-merge in CI.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `pytest -x` green (after deletions).
|
||||
- `cargo test --workspace` green.
|
||||
- `make ci-precheck` green.
|
||||
- E2E canary in CI passes.
|
||||
- Manual test: `headroom proxy start` boots the Rust binary; `curl -s http://127.0.0.1:8787/healthz` returns OK.
|
||||
- Operator deploys the new image to staging; cache hit rate ≥ Python baseline; no 5xx regressions in 24h.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A1 through PR-G3.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H2.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert` of just this PR restores the Python proxy. **Critical**: keep the previous container image around for at least 30 days so operators can pin to the pre-H1 image. Document the rollback path in `docs/operations/rollback.md`.
|
||||
|
||||
### Notes
|
||||
|
||||
- This is the largest single PR in the realignment. Coordinate with operations team.
|
||||
- Do NOT delete in one giant commit; split into a series of smaller commits within the PR (one per module deletion) for git-blame friendliness.
|
||||
|
||||
---
|
||||
|
||||
## PR-H2 — Retire LiteLLM Bedrock/Vertex backend
|
||||
|
||||
**Branch:** `realign-H2-retire-litellm-backends`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-H2-retire-litellm-backends`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** **-3,000 / +100**
|
||||
|
||||
### Scope
|
||||
After Phase D PR-D1..D4 added native Bedrock/Vertex Rust paths, the LiteLLM Python converter is no longer on any request path. Delete it.
|
||||
|
||||
### Files
|
||||
|
||||
**Delete:**
|
||||
- `headroom/backends/litellm.py` (~1500 LOC, the lossy converter)
|
||||
- `headroom/backends/__init__.py` if the only contents was the LiteLLM backend.
|
||||
|
||||
**Modify:**
|
||||
- `pyproject.toml` — remove `litellm` from dependencies. (Saves ~50 MB of installed-deps size.)
|
||||
- `headroom/providers/registry.py` — delete `litellm-bedrock`, `litellm-vertex` provider entries.
|
||||
- `headroom/cli/wrap/*` — verify no wrap CLIs route to LiteLLM (they shouldn't; they go through the proxy).
|
||||
|
||||
**Tests deleted:**
|
||||
- `tests/test_backends_litellm*.py`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `pytest -x` green.
|
||||
- A real Bedrock request through the Rust proxy succeeds (already validated in Phase D PR-D1 manual test).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-H1, PR-D1, PR-D2, PR-D4.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H3.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Restores LiteLLM. The Rust native paths from Phase D stay in place; both run side-by-side temporarily.
|
||||
|
||||
---
|
||||
|
||||
## PR-H3 — Final cleanup: orphaned modules, deps, docs
|
||||
|
||||
**Branch:** `realign-H3-final-cleanup`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-H3-final-cleanup`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** -2,000 / +500
|
||||
|
||||
### Scope
|
||||
Sweep up everything orphaned by H1+H2: unused imports, dead test fixtures, stale docs, legacy CLI commands. Update all README / wiki / docs to reflect the Rust-only proxy.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `README.md` — operator-facing docs reflect the Rust binary.
|
||||
- `wiki/` — refresh.
|
||||
- `docs/` — refresh.
|
||||
- `RUST_DEV.md` — final form (renamed to `DEV.md` since there's no longer a Python/Rust split).
|
||||
- `headroom/__init__.py` — drop unused module imports.
|
||||
- `pyproject.toml` — final dependency cleanup.
|
||||
- `Cargo.toml` — final workspace cleanup.
|
||||
|
||||
**Delete:**
|
||||
- `tests/parity/fixtures/` — most of these are used by Python parity comparators that no longer have a Python side. Keep only the fixtures that still gate Rust-vs-Rust parity (which is: none, after Phase H — though future ML compressor variants may want them back).
|
||||
- `crates/headroom-parity/` — the parity harness itself becomes irrelevant after Python side is gone. **Decision needed** (see `12-decisions-needed.md` Q3): keep parity-run as a "prior-version-vs-current-version" harness, or delete?
|
||||
|
||||
**Add:**
|
||||
- `CHANGELOG.md` entry: "**Breaking**: Python proxy retired. Operators must use the Rust binary `headroom-proxy`. See migration guide at `docs/operations/python-to-rust-migration.md`."
|
||||
- `docs/operations/python-to-rust-migration.md` — operator migration guide.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `git grep -i "uvicorn\|fastapi" headroom/` returns nothing in non-test code.
|
||||
- `pyproject.toml` runtime deps are minimal.
|
||||
- All docs build.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-H1, PR-H2.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
### Rollback
|
||||
|
||||
`git revert`. Restores cleanup; previous PRs stay.
|
||||
|
||||
---
|
||||
|
||||
## What survives in Python after Phase H
|
||||
|
||||
| Module | Role | Reason |
|
||||
|---|---|---|
|
||||
| `headroom/cli/wrap/*.py` | Agent launchers | Off-path; orchestrates filesystem + subprocess. Python is the right tool. |
|
||||
| `headroom/cli/{evals,init,install,learn,memory,perf,proxy,tools}.py` | CLI admin commands | Click-based; off-path. |
|
||||
| `headroom/rtk/installer.py` | RTK binary downloader | Off-path; filesystem operations. |
|
||||
| `headroom/providers/codex/install.py`, `claude/install.py` | Client config installation | Off-path; filesystem. |
|
||||
| `headroom/evals/`, `learn/`, `memory/` writers | Research / batch tooling | Off-path; long-running batch. |
|
||||
| `headroom/tokenizers/` | Parity backstop | Used only by parity harness if H3 keeps it. |
|
||||
| `headroom/telemetry/toin.py` | TOIN learning loop | Off-path; observation-only after Phase B PR-B5. |
|
||||
| `headroom/subscription/tracker.py`, `client.py` | Subscription usage poller | Off-path. |
|
||||
| `headroom/copilot_auth.py` | Copilot OAuth refresh | Off-path; specific to Copilot integration. |
|
||||
|
||||
## Phase H acceptance summary
|
||||
|
||||
After all 3 PRs land:
|
||||
|
||||
- ✅ Python proxy server retired
|
||||
- ✅ All Python proxy handlers deleted
|
||||
- ✅ Memory subsystem refactored or deleted
|
||||
- ✅ LiteLLM backend retired
|
||||
- ✅ Operators run only the Rust `headroom-proxy` binary
|
||||
- ✅ ~20 K LOC of Python deleted
|
||||
- ✅ Migration guide for operators
|
||||
|
||||
**Phase H is the deletion. The OSS surface area shrinks dramatically; maintenance debt drops; behavior becomes consistent across deployments.**
|
||||
@@ -0,0 +1,385 @@
|
||||
# Phase I — Test Infrastructure (Continuous, Parallel)
|
||||
|
||||
**Goal:** Build the test/CI surface that makes the realignment safe to land and stays safe afterward. This phase runs **in parallel** with all other phases — its PRs land alongside the corresponding feature work.
|
||||
|
||||
**Calendar:** Continuous. Each test PR pairs with the feature PR it gates.
|
||||
|
||||
**Shape:** ~10 PRs, mostly small, parallelizable.
|
||||
|
||||
---
|
||||
|
||||
## PR-I1 — SHA-256 byte-faithful round-trip test on recorded production payload
|
||||
|
||||
**Branch:** `realign-I1-sha256-round-trip`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I1-sha256-round-trip`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +400
|
||||
|
||||
### Scope
|
||||
Eliminate P6-63. The single most important regression test for cache safety. Records a real Anthropic `/v1/messages` payload (sanitized of secrets), sends it through the proxy with compression off, asserts SHA-256 byte-equality at the upstream mock.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `tests/fixtures/anthropic_messages_request_real.json` — sanitized real payload. Includes:
|
||||
- `system` as a list of blocks with `cache_control` markers
|
||||
- `tools[]` with non-trivial JSON Schema (nested properties, oneOf, definitions)
|
||||
- `messages[]` with mixed block types: text, image, thinking + signature, tool_use with non-trivial input, tool_result with array content + image
|
||||
- Non-ASCII characters (`🔥`, CJK)
|
||||
- Numeric values: `temperature: 1.0`, large integers, scientific notation
|
||||
- `cache_control` markers on `messages[*].content[*]`
|
||||
- `null` and absent fields side-by-side
|
||||
- `tests/fixtures/openai_chat_completions_real.json` — same shape for OpenAI Chat.
|
||||
- `tests/fixtures/openai_responses_real.json` — same shape for Responses, includes V4A patch, local_shell_call, reasoning, compaction items.
|
||||
- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_anthropic_messages_passthrough`
|
||||
- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_anthropic_messages_compression_off_via_auth_mode`
|
||||
- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_openai_chat`
|
||||
- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_openai_responses`
|
||||
- `tests/test_python_byte_faithful.py::test_sha256_round_trip_anthropic_passthrough` — Python side, gates Phase H readiness.
|
||||
|
||||
**Modify:**
|
||||
- `Makefile` — `make test-byte-faithful` target that runs all of the above.
|
||||
- `.github/workflows/rust.yml` — make `make test-byte-faithful` a per-PR gate.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All tests pass after Phase A PR-A3, PR-A4 land.
|
||||
- Test runs in <5 seconds.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A1.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H1 (Phase H gating).
|
||||
|
||||
---
|
||||
|
||||
## PR-I2 — SSE corner-case fixtures + fuzz tests
|
||||
|
||||
**Branch:** `realign-I2-sse-corner-cases`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I2-sse-corner-cases`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +800
|
||||
|
||||
### Scope
|
||||
Eliminate P6-66, P6-71. Record fixtures for every SSE corner case the audit identified.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/anthropic_thinking_with_signature.sse`
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/anthropic_interleaved_blocks.sse` (synthetic; locks the index-keyed model)
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/anthropic_input_json_delta_split_utf8.sse` (4-byte emoji split across chunks)
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/anthropic_ping_mid_stream.sse`
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/anthropic_error_mid_stream.sse`
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/openai_chat_tool_call_split.sse`
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/openai_chat_done_with_trailing_whitespace.sse`
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/openai_responses_out_of_order_done.sse`
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/openai_429_as_application_json.http` (HTTP error, not SSE)
|
||||
- `crates/headroom-proxy/tests/fixtures/sse/anthropic_tcp_drop_before_message_stop.sse`
|
||||
- `crates/headroom-proxy/tests/integration_sse_fixtures.rs` — runs every fixture against the parser; asserts expected state.
|
||||
- Property test in `crates/headroom-proxy/tests/proptest_sse.rs::sse_parser_no_panic_on_arbitrary_bytes`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All fixtures parse correctly.
|
||||
- Property test runs 10K random byte sequences without panic.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-C1.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## PR-I3 — Property tests for compression invariants
|
||||
|
||||
**Branch:** `realign-I3-compression-proptest`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I3-compression-proptest`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +500
|
||||
|
||||
### Scope
|
||||
Property tests that exercise the realigned compressor invariants:
|
||||
1. **Determinism**: `compress(input) == compress(input)` for any valid input.
|
||||
2. **Idempotence**: `compress(compress(input).output) == compress(input).output` (compressing already-compressed content is a no-op).
|
||||
3. **Token-non-increasing**: `tokens(output) <= tokens(input)` for any valid input — fallback ensures this.
|
||||
4. **Position preservation**: For all valid block arrays, `len(compressed) == len(original)`; block types match per index; `tool_use_id` / `call_id` preserved.
|
||||
5. **Frozen-prefix integrity**: For any `frozen_count`, messages `0..frozen_count` are byte-equal in input and output.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-core/tests/proptest_compression.rs` — proptest strategies for `Block`, `Message`, `RequestBody`. Five property tests above.
|
||||
- `crates/headroom-core/tests/proptest_ccr.rs` — round-trip property test: `decompress(compress(content)) == content` for any content.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All property tests pass with `cases = 1000`.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B4.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## PR-I4 — Real-traffic shadow test (Python vs Rust)
|
||||
|
||||
**Branch:** `realign-I4-shadow-test`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I4-shadow-test`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** +1000
|
||||
|
||||
### Scope
|
||||
Eliminate P6-67. A canary deployment runs the Python proxy and the Rust proxy side-by-side; for every request, both produce upstream-bound bytes; a comparator hashes both and reports SHA-256 mismatch percentage. Goal: 99.9% byte-equality before Phase H deletes Python.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `e2e/shadow/runner.py` — splits incoming requests into "primary" (Python, response goes to client) and "shadow" (Rust, response discarded). Hashes upstream-bound bytes from both; reports per-request, per-endpoint, per-auth-mode mismatch rates.
|
||||
- `e2e/shadow/dashboard.py` — Grafana dashboard JSON that visualizes the shadow comparison.
|
||||
- `docs/operations/shadow-deploy.md` — operator guide for running the shadow test.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Shadow test runs against a non-trivial corpus (10K requests) and reports.
|
||||
- Mismatch rate <0.1% before declaring Phase H ready.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-A1 through PR-G3.
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-H1.
|
||||
|
||||
---
|
||||
|
||||
## PR-I5 — Promote stub parity comparators to real
|
||||
|
||||
**Branch:** `realign-I5-parity-stubs-to-real`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I5-parity-stubs-to-real`
|
||||
**Risk:** **MEDIUM**
|
||||
**LOC:** +800
|
||||
|
||||
### Scope
|
||||
Eliminate P6-64. `crates/headroom-parity/src/lib.rs:172-174` stubs three comparators with `bail!()`:
|
||||
- `ccr` (25 fixtures recorded; comparator is `Skipped`)
|
||||
- `log_compressor` (20 fixtures recorded; comparator is `Skipped`)
|
||||
- `cache_aligner` (20 fixtures recorded; comparator is `Skipped`)
|
||||
|
||||
Build real comparators that exercise the Rust port against the recorded Python fixtures.
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `crates/headroom-parity/src/lib.rs` — replace `stub_comparator!(CCRComparator, ...)` etc. with real impls.
|
||||
- Add `CcrComparator`, `LogCompressorComparator`, `CacheAlignerComparator` modules.
|
||||
|
||||
**Tests added:**
|
||||
- Each comparator has a `harness_reports_match_for_real_fixture` test.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All three comparators run against their recorded fixtures.
|
||||
- Mismatch rate is 0% (parity locked).
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B3 (LogCompressor live in proxy); PR-B7 (CCR hardening); PR-A2 (CacheAligner detector).
|
||||
|
||||
### Blocks
|
||||
|
||||
PR-I6.
|
||||
|
||||
---
|
||||
|
||||
## PR-I6 — Make `make test-parity` a per-PR CI gate
|
||||
|
||||
**Branch:** `realign-I6-parity-per-pr-gate`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I6-parity-per-pr-gate`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +50
|
||||
|
||||
### Scope
|
||||
Eliminate P6-65. Today parity is a soft nightly with `continue-on-error: true`. Move it to per-PR with `Diff` failures blocking merge; `Skipped` allowed (so still-stubbed comparators don't block).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `.github/workflows/rust.yml:125-149` — move parity job from `cron` schedule to `pull_request` trigger. Remove `continue-on-error`. Set parity-run flags so `Skipped` is acceptable but `Diff` fails the build.
|
||||
- `Makefile` — `test-parity` already exists; ensure it's invokable in CI.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A purposely-broken Rust port that diverges from a recorded fixture fails CI on the next PR.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-I5.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## PR-I7 — Cache hot zone non-mutation tests
|
||||
|
||||
**Branch:** `realign-I7-cache-hot-zone-tests`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I7-cache-hot-zone-tests`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +600
|
||||
|
||||
### Scope
|
||||
Test that nothing — compression, memory injection, tool registration — mutates the cache hot zone.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::system_byte_equal_under_compression`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::tools_byte_equal_under_compression` (modulo Phase E sort + schema-key sort, which is deterministic — assert post-sort byte-equal)
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::frozen_messages_byte_equal_under_compression`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::reasoning_encrypted_content_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::thinking_signature_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::redacted_thinking_data_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::compaction_encrypted_content_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::v4a_patch_diff_byte_equal`
|
||||
- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::local_shell_call_argv_array_preserved`
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- All tests pass.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B2.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## PR-I8 — Tool-definition byte-stability snapshot tests
|
||||
|
||||
**Branch:** `realign-I8-tool-def-snapshot`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I8-tool-def-snapshot`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +300
|
||||
|
||||
### Scope
|
||||
For every tool definition Headroom auto-injects (`ccr_retrieve`, `memory_*`), pin the bytes via golden-file snapshot. Any change to a definition fails CI; a deliberate change requires updating the snapshot. This prevents accidental cache busts on Headroom deploys.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `crates/headroom-core/tests/tool_def_byte_stability.rs::ccr_retrieve_definition_anthropic_byte_stable`
|
||||
- `crates/headroom-core/tests/tool_def_byte_stability.rs::ccr_retrieve_definition_openai_byte_stable`
|
||||
- `crates/headroom-core/tests/tool_def_byte_stability.rs::memory_save_definition_byte_stable`
|
||||
- `crates/headroom-core/tests/tool_def_byte_stability.rs::memory_search_definition_byte_stable`
|
||||
- Golden files under `crates/headroom-core/tests/golden/tool_defs/`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests pass.
|
||||
- Renaming a field in a tool definition fails CI; updating the golden file fixes it.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-B7.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## PR-I9 — Continuous cache-hit-rate alarm
|
||||
|
||||
**Branch:** `realign-I9-cache-hit-rate-alarm`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I9-cache-hit-rate-alarm`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +200
|
||||
|
||||
### Scope
|
||||
A Prometheus alarm rule that fires when the per-session cache hit rate (`proxy_cache_hit_rate_per_session`) drops below a baseline (90% of yesterday's rolling p50) for >15 minutes. Catches drift in production.
|
||||
|
||||
### Files
|
||||
|
||||
**Add:**
|
||||
- `docs/operations/prometheus_rules.yaml` — alarm rule definition.
|
||||
- `docs/operations/runbook.md` — what to do when the alarm fires.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Rule passes `promtool check rules`.
|
||||
- Runbook reviewed.
|
||||
|
||||
### Blocked by
|
||||
|
||||
PR-G3.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## PR-I10 — Replace fake RTK shim with real RTK in wrap E2E
|
||||
|
||||
**Branch:** `realign-I10-real-rtk-in-e2e`
|
||||
**Worktree:** `~/claude-projects/headroom-worktrees/realign-I10-real-rtk-in-e2e`
|
||||
**Risk:** **LOW**
|
||||
**LOC:** +200
|
||||
|
||||
### Scope
|
||||
Eliminate P6-72. `e2e/wrap/run.py:250-267` has an `rtk` shim that just prints "rtk shim" and exits 0. Replace with real RTK invocation in CI, OR keep the shim but add an explicit assertion that the shim was used (so it doesn't silently mask a missing RTK install).
|
||||
|
||||
### Files
|
||||
|
||||
**Modify:**
|
||||
- `e2e/wrap/run.py:250-267` — switch to real RTK download in CI; cache the binary.
|
||||
- `.github/workflows/wrap-e2e.yml` — pin RTK version.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- E2E tests download real RTK and exercise its rewrite behavior.
|
||||
|
||||
### Blocked by
|
||||
|
||||
None.
|
||||
|
||||
### Blocks
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## Phase I acceptance summary
|
||||
|
||||
After all 10 PRs land:
|
||||
|
||||
- ✅ SHA-256 byte-faithful round-trip test gates CI
|
||||
- ✅ SSE corner-case fixtures + fuzz tests
|
||||
- ✅ Property tests for compression invariants
|
||||
- ✅ Real-traffic shadow test comparing Python vs Rust
|
||||
- ✅ Stub parity comparators promoted to real
|
||||
- ✅ `make test-parity` is a per-PR gate
|
||||
- ✅ Cache hot zone non-mutation tests
|
||||
- ✅ Tool-definition byte-stability snapshot tests
|
||||
- ✅ Cache-hit-rate Prometheus alarm
|
||||
- ✅ Real RTK in wrap E2E
|
||||
|
||||
**Phase I retires P6-63 through P6-72.**
|
||||
|
||||
After Phase I, regressing the realignment requires actively breaking tests — the cache safety properties become continuously enforced.
|
||||
@@ -0,0 +1,196 @@
|
||||
# 12 — Decisions Needed
|
||||
|
||||
Open questions the realignment can't resolve unilaterally. Greenlight or alternative each before the corresponding PR lands.
|
||||
|
||||
---
|
||||
|
||||
## Q1. Phase A timing — land tonight or wait?
|
||||
|
||||
**Recommendation:** Land **PR-A1 tonight**. It's a small diff (-180/+30) that eliminates the worst cache-killer cluster (P0-3, P0-4, P0-5 stop firing immediately). The proxy goes to passthrough on `/v1/messages`; compression returns in Phase B. Net positive because today's compression is actively destroying cache hit rate.
|
||||
|
||||
PR-A2 through PR-A8 land over the rest of the week.
|
||||
|
||||
**Alternative:** Hold all of Phase A until the synthesis is "perfect." Risk: cache hit rate stays poor.
|
||||
|
||||
---
|
||||
|
||||
## Q2. ICM removal scope — Tier 1+2, or include Tier 3?
|
||||
|
||||
**Recommendation: Tier 1 + Tier 2 in Phase B PR-B1** (~10K LOC).
|
||||
|
||||
- **Tier 1** (ICM proper): `intelligent_context.py`, `manager.rs`, `icm.rs`, the proxy call site.
|
||||
- **Tier 2** (subsystems whose only consumer is ICM): `RollingWindow`, `ProgressiveSummarizer`, `scoring.py`, `tool_crusher.py`, `MessageScorer`, all of `crates/headroom-core/src/scoring/` and `relevance/`, most of `context/` (keep `safety.rs`).
|
||||
- **Tier 3** (separable cleanup): `CacheAligner` rewrite path is in Phase A PR-A2 (already scheduled). Memory `_inject_system_context` paths in Phase A PR-A2 + Phase B PR-B6 (already scheduled).
|
||||
|
||||
So "Tier 1 + Tier 2" is the right scope for the Phase B big-delete PR; Tier 3 is already covered by Phase A and Phase B's other PRs.
|
||||
|
||||
**Alternative:** Stop at Tier 1 (just ICM proper). Risk: ~6 K LOC of dead-but-still-imported scoring/relevance machinery; future contributors won't know it's dead.
|
||||
|
||||
---
|
||||
|
||||
## Q3. MessageScorer Rust port — delete?
|
||||
|
||||
**Recommendation: Delete.**
|
||||
|
||||
The PR #338 / #343 port (April 2026) was investment in the wrong abstraction (per Agent G's audit: scoring's only consumer is `DropByScoreStrategy::try_fit`, which Phase B retires). Keeping it as a dead crate creates maintenance debt and confusion. Sunk cost stays sunk; the parity-harness scaffolding learnings carry forward to live-zone work where they actually matter.
|
||||
|
||||
Folded into Phase B PR-B1.
|
||||
|
||||
**Alternative:** Keep the crate around as off-path "in case scoring is needed later." Risk: dead-code review burden every PR.
|
||||
|
||||
---
|
||||
|
||||
## Q4. Stage 3g (lossless-first compression pipeline, issue #315) — re-scope or close?
|
||||
|
||||
**Context:** Per project memory `~/.claude/projects/-Users-tchopra-claude-projects-headroom/memory/project_lossless_first_pipeline.md`, Stage 3g was queued to formalize "lossless-then-lossy-then-CCR ordering as a `CompressionPipeline` orchestrator + `LosslessTransform`/`LossyTransform` traits." The plan assumed an ICM-style orchestrator over the messages array.
|
||||
|
||||
**Recommendation:** **Re-scope** issue #315 to "live-zone-only pipeline orchestrator." The traits stay (`LosslessTransform`/`LossyTransform`); the scope changes from "history compactor" to "live-zone block dispatcher." This is what Phase B PR-B2 builds. Update issue #315's body to reflect the realignment.
|
||||
|
||||
**Alternative:** Close issue #315 and treat Phase B PR-B2 as fulfilling its intent. Risk: history of the decision is lost.
|
||||
|
||||
---
|
||||
|
||||
## Q5. Headroom Loop / AWS Marketplace BYOC — affected scope?
|
||||
|
||||
**Context:** Per project memory `project_headroom_loop.md` (enterprise paid product) and `project_headroom_aws_marketplace.md` (BYOC CFN stack in customer VPC). Both depend on the OSS proxy.
|
||||
|
||||
**Recommendation:** The realignment **strengthens** both:
|
||||
- Headroom Loop's value proposition is "trace stream + enterprise compression policy"; Phase F's auth-mode policy is exactly the surface Loop wants to gate on.
|
||||
- AWS Marketplace BYOC's pitch is "context compression in front of Bedrock"; Phase D's native Bedrock support makes that pitch real (today's LiteLLM-converted Bedrock path was fake; Phase D fixes it).
|
||||
|
||||
No re-scoping needed; revisit after Phase D lands.
|
||||
|
||||
**Alternative:** Pause Headroom Loop / Marketplace work until Phase D completes. Recommended if their roadmap conflicts with Phase D timing.
|
||||
|
||||
---
|
||||
|
||||
## Q6. `make test-parity` per-PR gate — enable now or wait?
|
||||
|
||||
**Recommendation:** Enable now (Phase I PR-I6) with the existing stubs. `Skipped` is permitted; `Diff` fails the build. As Phase I PR-I5 promotes stubs to real comparators, the per-PR gate gradually tightens.
|
||||
|
||||
**Alternative:** Wait until all stubs are real. Risk: parity divergence merges silently for the next month.
|
||||
|
||||
---
|
||||
|
||||
## Q7. Operator config switch — explicit `HEADROOM_PROXY_BACKEND` env var, or implicit?
|
||||
|
||||
**Context:** During Phase H rollout, operators need a way to choose Python vs Rust proxy.
|
||||
|
||||
**Recommendation:** Add `HEADROOM_PROXY_BACKEND={python|rust}` env var in Phase H PR-H1; default to `rust` once the canary in Phase I PR-I4 confirms ≥99.9% byte-equality. Keep the Python proxy alive in the codebase for 30 days post-Phase-H as an explicit rollback target. After 30 days of stable Rust operation, run Phase H PR-H2/H3 to delete Python.
|
||||
|
||||
**Alternative:** Cut over implicitly (`headroom proxy start` always uses Rust after Phase H). Riskier; no clean rollback path.
|
||||
|
||||
---
|
||||
|
||||
## Q8. Container image strategy — single binary or multi-stage?
|
||||
|
||||
**Recommendation:** Single binary (`headroom-proxy` Rust). Container is `FROM scratch` or `FROM gcr.io/distroless/static`. Image size drops from ~500 MB (with Python + LiteLLM + ONNX models) to ~50 MB.
|
||||
|
||||
**Alternative:** Multi-stage Docker with Rust binary + Python sidecar (for evals/learn/memory writers). Recommended only if those subsystems become production-relevant; today they're CLI tools.
|
||||
|
||||
---
|
||||
|
||||
## Q9. RTK proxy-side invocation — ever revisit?
|
||||
|
||||
**Recommendation:** **No, document the decision in `docs/rtk-architecture.md`** (Phase G PR-G3). The argument:
|
||||
1. Cache hot zone risk: shell-out + buffer per tool result is correctness-fragile.
|
||||
2. Parallel implementation: `crates/headroom-core/src/transforms/log_compressor.rs` covers post-hoc log/output compression; RTK rewrites *commands* (different value).
|
||||
3. RTK itself is a third-party binary the team doesn't control; an upstream version change silently busts cache.
|
||||
|
||||
If a future requirement emerges (e.g., "Headroom must compress shell output for users who don't run wrap"), reconsider with explicit cache-safety design.
|
||||
|
||||
**Alternative:** Build proxy-side RTK as a feature-flagged opt-in. Recommended only if the wrap-CLI breadth (PR-G1) doesn't cover enough surface.
|
||||
|
||||
---
|
||||
|
||||
## Q10. Bedrock/Vertex priority — parallel with proxy port (Phase D in calendar) or after Phase H?
|
||||
|
||||
**Recommendation:** **Parallel.** Phase D blocks H2 (Python LiteLLM retirement) but not H1 (Python proxy retirement). Run Phase D and Phase C/E/F/G concurrently.
|
||||
|
||||
**Alternative:** Sequential, Phase D after Phase H. Risk: Bedrock/Vertex users stay on the broken Python LiteLLM path for an extra month.
|
||||
|
||||
---
|
||||
|
||||
## Q11. Memory subsystem — auto-tail mode default, or tool-only?
|
||||
|
||||
**Recommendation:** Auto-tail mode default in Phase B PR-B6, with tool-only mode behind a flag. Migrate users to tool-only over the next 6 months once docs and tooling are mature. Auto-tail is byte-deterministic (per the cache-safety invariant) and matches existing UX.
|
||||
|
||||
**Alternative:** Force tool-only immediately. Risk: breaks customers' existing memory-augmented prompts.
|
||||
|
||||
---
|
||||
|
||||
## Q12. Parity harness post-Phase-H — keep or delete?
|
||||
|
||||
**Context:** After Phase H deletes Python, `crates/headroom-parity/` no longer has a Python side to compare against. Per Phase H PR-H3, this is a decision point.
|
||||
|
||||
**Recommendation:** **Repurpose**, don't delete. Rename to `crates/headroom-version-parity/` and use it to compare current-Rust-version vs previous-Rust-version on the recorded fixtures. Catches Rust-vs-Rust regressions during future ML compressor variants (e.g., when Kompress is ported to Rust via `ort`).
|
||||
|
||||
**Alternative:** Delete entirely. Save ~2K LOC. Risk: no automated regression test for compressor changes.
|
||||
|
||||
---
|
||||
|
||||
## Q13. Auth-mode UA detection list — which CLIs to recognize?
|
||||
|
||||
**Phase F PR-F1 starts with this list:**
|
||||
- `claude-cli/` (Anthropic CLI)
|
||||
- `claude-code/` (Claude Code)
|
||||
- `codex-cli/` (Codex CLI)
|
||||
- `cursor/` (Cursor IDE)
|
||||
- `claude-vscode/`
|
||||
- `github-copilot/`
|
||||
- `anthropic-cli/`
|
||||
- `antigravity/` (Cloudcode Antigravity)
|
||||
|
||||
**Recommendation:** Extend over time as new CLIs emerge. Alphabetic sort for determinism. Document in `docs/auth-modes.md`.
|
||||
|
||||
**Alternative:** Start with a smaller list; expand reactively. Risk: subscription users mis-classified as PAYG and fingerprint-leaked.
|
||||
|
||||
---
|
||||
|
||||
## Q14. The ICM removal blast radius — confirm acceptable
|
||||
|
||||
**Counts:**
|
||||
- Lines deleted (Python): ~3,300
|
||||
- Lines deleted (Rust): ~4,500
|
||||
- Files deleted: ~30
|
||||
- Tests deleted: ~50
|
||||
- PRs that recently merged but become wasted work: PR #338, PR #343 (MessageScorer Rust port)
|
||||
- Project memory updates needed: 1 (the "53270 lines" content_router.py figure was wrong by 25× — already corrected in `MEMORY.md`).
|
||||
|
||||
**Recommendation:** Acceptable. The cache-killer bugs cost more than the deleted code's hypothetical future value.
|
||||
|
||||
---
|
||||
|
||||
## Q15. Calendar + capacity — sequential or parallel?
|
||||
|
||||
**Sequential calendar:** ~13 weeks. One contributor working through phases A→I.
|
||||
**Parallel calendar:** ~8 weeks with 2-3 contributors splitting along these natural boundaries:
|
||||
- Lead: Phase A (lockdown), Phase B (live-zone), Phase H (retirement) — the critical path.
|
||||
- Contributor 2: Phase C (Rust proxy paths), Phase D (Bedrock/Vertex). Self-contained.
|
||||
- Contributor 3 (optional): Phase E (cache stabilization), Phase F (auth-mode), Phase G (RTK + obs), Phase I (test infra). Mostly independent.
|
||||
|
||||
**Recommendation:** Parallel. The bug list is real and the cache hit rate is hemorrhaging in production today.
|
||||
|
||||
---
|
||||
|
||||
## Quick answer template
|
||||
|
||||
For decision sign-off, fill in this block:
|
||||
|
||||
```
|
||||
Q1 (Phase A timing): [ ] tonight [ ] wait
|
||||
Q2 (ICM scope): [ ] Tier 1+2 [ ] Tier 1 only [ ] all 3 tiers
|
||||
Q3 (MessageScorer): [ ] delete [ ] keep
|
||||
Q4 (issue #315): [ ] re-scope [ ] close
|
||||
Q5 (Loop/Marketplace):[ ] proceed unchanged [ ] pause until D
|
||||
Q6 (parity gate): [ ] enable now [ ] wait
|
||||
Q7 (operator switch): [ ] env var w/ default rust [ ] implicit cutover
|
||||
Q8 (container): [ ] single binary [ ] multi-stage
|
||||
Q9 (RTK proxy-side): [ ] document never [ ] feature-flag for future
|
||||
Q10 (Bedrock priority):[ ] parallel [ ] sequential after H
|
||||
Q11 (memory mode): [ ] auto-tail default [ ] tool-only force
|
||||
Q12 (parity harness): [ ] repurpose [ ] delete
|
||||
Q13 (UA list): [ ] approve list [ ] revise: ___________
|
||||
Q14 (ICM blast radius): [ ] accept [ ] reduce scope
|
||||
Q15 (calendar): [ ] parallel (2-3 contributors) [ ] sequential
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# Headroom Realignment — Index
|
||||
|
||||
**Status:** Drafted 2026-05-01 from a 10-agent deep audit against `~/Downloads/llm-proxy-compression-guide.md`.
|
||||
**Owner:** chopratejas
|
||||
**Goal:** Move the entire codebase to Rust, preserve prefix cache, retain compression value, integrate RTK end-to-end, and gate compression policy by auth mode (PAYG / OAuth / subscription).
|
||||
|
||||
## Read in this order
|
||||
|
||||
1. [00-overview.md](./00-overview.md) — executive summary; the wrong mental model; what changes
|
||||
2. [01-bug-list.md](./01-bug-list.md) — comprehensive ranked bug list with file:line and guide §
|
||||
3. [02-architecture.md](./02-architecture.md) — the realigned target architecture
|
||||
4. Phase docs (PR-by-PR, executable):
|
||||
- [03-phase-A-lockdown.md](./03-phase-A-lockdown.md) — **start here**: stop-the-bleeding (8 PRs, ~1 week)
|
||||
- [04-phase-B-live-zone.md](./04-phase-B-live-zone.md) — live-zone-only compression (7 PRs, ~2 weeks)
|
||||
- [05-phase-C-rust-proxy.md](./05-phase-C-rust-proxy.md) — port handlers to Rust (5 PRs, ~3 weeks)
|
||||
- [06-phase-D-bedrock-vertex.md](./06-phase-D-bedrock-vertex.md) — native envelopes (4 PRs, ~2 weeks)
|
||||
- [07-phase-E-cache-stabilization.md](./07-phase-E-cache-stabilization.md) — Phase 3 stabilization (6 PRs, ~1 week)
|
||||
- [08-phase-F-auth-mode.md](./08-phase-F-auth-mode.md) — auth-mode policy gates (4 PRs, ~1 week)
|
||||
- [09-phase-G-rtk-observability.md](./09-phase-G-rtk-observability.md) — RTK breadth + metrics (3 PRs, ~1 week)
|
||||
- [10-phase-H-python-retirement.md](./10-phase-H-python-retirement.md) — delete Python proxy (3 PRs, ~2 weeks)
|
||||
- [11-phase-I-test-infra.md](./11-phase-I-test-infra.md) — test/CI gates (parallel)
|
||||
5. [12-decisions-needed.md](./12-decisions-needed.md) — open questions
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Branch name:** `realign-<phase-letter><pr-num>-<slug>`. Example: `realign-A1-icm-passthrough`.
|
||||
- **Worktree path:** `~/claude-projects/headroom-worktrees/realign-<phase><num>-<slug>`. Use `git worktree add` so each PR is an isolated checkout.
|
||||
- **Commit prefix:** `fix:` for Rust-migration phase commits (per project memory — `feat:` would inflate semantic-release version).
|
||||
- **No `Co-Authored-By: Claude` trailer** (per project memory).
|
||||
- **Pre-push gate:** `make ci-precheck` per project memory; never push without it.
|
||||
|
||||
## Phase totals
|
||||
|
||||
| Phase | PRs | LOC delta (est.) | Calendar (sequential) |
|
||||
|---|---:|---:|---:|
|
||||
| A — Lockdown | 8 | -200 / +400 | 1 week |
|
||||
| B — Live-zone engine | 7 | **-10,000 / +1,500** | 2 weeks |
|
||||
| C — Rust proxy paths | 5 | -2,000 / +5,000 | 3 weeks |
|
||||
| D — Bedrock/Vertex native | 4 | -800 / +2,500 | 2 weeks |
|
||||
| E — Cache stabilization | 6 | -100 / +900 | 1 week |
|
||||
| F — Auth-mode policy | 4 | -50 / +600 | 1 week |
|
||||
| G — RTK + observability | 3 | -50 / +400 | 1 week |
|
||||
| H — Python retirement | 3 | **-15,000 / +200** | 2 weeks |
|
||||
| I — Test infra | parallel | +2,000 | continuous |
|
||||
| **Total** | **40** | **~-28,000 / +13,500** | **~13 weeks** sequential, **~8 weeks** parallel |
|
||||
|
||||
## Cross-cutting invariants
|
||||
|
||||
These never get violated by any PR:
|
||||
|
||||
1. Bytes that the proxy doesn't intend to modify must arrive at upstream **byte-equal** (SHA-256) to bytes that arrived at the proxy. (§1.9)
|
||||
2. The cache hot zone — system, tools, old turns, reasoning/thinking/redacted/compaction items — is never modified. (§10)
|
||||
3. Compression is **append-only**: only the live zone (latest user message, latest tool/function/shell/patch outputs) is ever rewritten. (§6.4 + §10.3)
|
||||
4. Compression is deterministic: same input bytes → same output bytes. (§7.1)
|
||||
5. Tool definitions are **normalized** (sorted), never compressed. (§8.5)
|
||||
6. `signature`, `encrypted_content`, `redacted_thinking.data`, `compaction.encrypted_content` are passthrough-only. (§2.7, §2.8, §4.3, §4.8, §10.1)
|
||||
7. TOIN never alters request-time decisions; it observes and publishes recommendations between deploys. (§7.1, §11.17)
|
||||
8. CCR markers and the `ccr_retrieve` tool are present **on every request** for a session that ever did CCR — never toggled. (§6.3 #2)
|
||||
9. `Authorization` header is forwarded byte-faithfully and never logged or persisted unredacted.
|
||||
10. Auth mode (PAYG / OAuth / subscription) gates compression policy; subscription mode runs in stealth (no `X-Headroom-*` upstream, no beta drift, no UA mutation, no `accept-encoding` strip).
|
||||
|
||||
## Preserved primitives (per user direction)
|
||||
|
||||
- **TOIN** — refactored to strict observation-only; per-tenant aggregation key.
|
||||
- **CCR** — hardened with persistent backend + always-on tool registration.
|
||||
- **Kompress-base** — stays as plain-text compressor (§8.6); Rust port via `ort` later.
|
||||
- **ContentRouter** — the architecturally correct piece (~2150 LOC); ported to Rust as the live-zone block dispatcher.
|
||||
- **Type-aware compressors** — SmartCrusher, Code, Log, Search, Diff (already in Rust); kept.
|
||||
- **`signals/` Rust trait module** — keeps; drives live-zone consumers.
|
||||
- **`tokenizer/` Rust** — keeps.
|
||||
- **`safety.rs`** — tool-pair atomicity logic; moved to `transforms/safety.rs` after Phase B.
|
||||
|
||||
## Retired (~25K LOC)
|
||||
|
||||
- ICM (Python `intelligent_context.py`, Rust `context/manager.rs`)
|
||||
- `RollingWindow`, `ProgressiveSummarizer`, `scoring.py`, `tool_crusher.py` (Python)
|
||||
- `crates/headroom-core/src/scoring/`, `relevance/`, most of `context/` (Rust)
|
||||
- `crates/headroom-proxy/src/compression/icm.rs`
|
||||
- `headroom/transforms/cache_aligner.py` rewrite path (keep detector + warning)
|
||||
- `headroom/proxy/server.py`, `handlers/anthropic.py`, `handlers/openai.py`, `handlers/streaming.py`, `handlers/gemini.py`, `responses_converter.py`, `memory_handler.py`, `memory_tool_adapter.py`, `semantic_cache.py`, `batch.py` — once Rust hits parity (Phase H)
|
||||
- `headroom/backends/litellm.py` Bedrock/Vertex converter — replaced by native envelopes (Phase D)
|
||||
- MessageScorer Rust port (PR #338, #343) — wasted work; deleted in Phase B
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
# Headroom Rust Rewrite — Developer Guide
|
||||
|
||||
This document covers the Rust port of Headroom. It is the only new top-level
|
||||
doc created in Phase 0; longer-form design/plan writeups live elsewhere and
|
||||
are not versioned in this repo.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
```
|
||||
Cargo.toml # workspace root
|
||||
rust-toolchain.toml # pins stable rustc with rustfmt+clippy
|
||||
crates/
|
||||
headroom-core/ # library: shared types + transform trait surface
|
||||
headroom-proxy/ # binary: axum /healthz (Phase 2 grows this)
|
||||
headroom-py/ # PyO3 cdylib exposing `headroom._core`
|
||||
headroom-parity/ # lib + `parity-run` CLI for Python parity tests
|
||||
tests/parity/
|
||||
fixtures/<transform>/*.json # recorded Python outputs (Phase 1 ports match)
|
||||
recorder.py # Python-side fixture recorder
|
||||
scripts/record_fixtures.py # entry point for running the recorder
|
||||
```
|
||||
|
||||
`cargo build --workspace` builds every crate. `default-members` drops
|
||||
`headroom-py` from `cargo run`/bare-`cargo test` flows so that `cargo test
|
||||
--workspace` does not try to execute the PyO3 cdylib standalone (it can't
|
||||
find `libpython` without a Python interpreter hosting it).
|
||||
|
||||
## Common commands
|
||||
|
||||
`just` is not installed on dev boxes here; a `Makefile` at the repo root
|
||||
exposes the same targets:
|
||||
|
||||
| Target | What it does |
|
||||
| --- | --- |
|
||||
| `make test` | `cargo test --workspace` |
|
||||
| `make test-parity` | Builds `headroom-py` via maturin, runs `parity-run run` |
|
||||
| `make bench` | `cargo bench --workspace` |
|
||||
| `make build-proxy` | Release-builds `headroom-proxy`, strips, prints size |
|
||||
| `make build-wheel` | `maturin build --release -m crates/headroom-py/pyproject.toml` |
|
||||
| `make fmt` | `cargo fmt --all` |
|
||||
| `make lint` | `cargo fmt --check` + `cargo clippy --workspace -- -D warnings` |
|
||||
|
||||
## Running the proxy
|
||||
|
||||
`headroom-proxy` is a transparent reverse proxy. Phase 1 forwards HTTP/1.1,
|
||||
HTTP/2, SSE, and WebSocket traffic verbatim to a configured upstream — no
|
||||
provider logic yet. The intent is that operators run the existing Python
|
||||
proxy on a private port and put `headroom-proxy` on the public port pointed
|
||||
at it; end users notice nothing.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make build-proxy
|
||||
./target/release/headroom-proxy --help
|
||||
|
||||
# Run against a local upstream
|
||||
./target/release/headroom-proxy \
|
||||
--listen 0.0.0.0:8787 \
|
||||
--upstream http://127.0.0.1:8788
|
||||
|
||||
# Health checks
|
||||
curl -s http://127.0.0.1:8787/healthz # => {"ok":true,...}
|
||||
curl -s http://127.0.0.1:8787/healthz/upstream # => 200 if upstream reachable
|
||||
```
|
||||
|
||||
### Operator runbook (Phase 1 cutover)
|
||||
|
||||
```bash
|
||||
# 1. Move the Python proxy to a private port (e.g. 8788)
|
||||
HEADROOM_HOST=127.0.0.1 HEADROOM_PORT=8788 python -m headroom.proxy & # or your existing launcher
|
||||
|
||||
# 2. Run the Rust proxy on the previously-public port (8787) pointing at it
|
||||
./target/release/headroom-proxy --listen 0.0.0.0:8787 --upstream http://127.0.0.1:8788 &
|
||||
|
||||
# 3. End users keep hitting :8787 unchanged.
|
||||
# 4. Confirm passthrough:
|
||||
curl -si http://127.0.0.1:8787/v1/models
|
||||
# 5. Rollback = stop the Rust proxy and rebind Python back to 8787.
|
||||
```
|
||||
|
||||
### Configuration flags
|
||||
|
||||
| Flag | Env var | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `--listen` | `HEADROOM_PROXY_LISTEN` | `0.0.0.0:8787` | bind address |
|
||||
| `--upstream` | `HEADROOM_PROXY_UPSTREAM` | (required) | base URL the proxy forwards to |
|
||||
| `--upstream-timeout` | | `600s` | end-to-end request timeout (long for streams) |
|
||||
| `--upstream-connect-timeout` | | `10s` | TCP/TLS connect timeout |
|
||||
| `--max-body-bytes` | | `100MB` | for buffered cases; streams bypass |
|
||||
| `--log-level` | | `info` | `RUST_LOG`-style filter |
|
||||
| `--rewrite-host` / `--no-rewrite-host` | | rewrite | rewrite Host to upstream (default) |
|
||||
| `--graceful-shutdown-timeout` | | `30s` | wait for in-flight on SIGTERM/SIGINT |
|
||||
|
||||
### Picking the next port: invocation telemetry
|
||||
|
||||
Before porting another Python compressor to Rust, check what's actually
|
||||
running. The Python proxy already exposes per-transform telemetry on
|
||||
`/stats` (`headroom.proxy.prometheus_metrics`):
|
||||
|
||||
```bash
|
||||
# Top compressors by invocation count (last process lifetime)
|
||||
curl -s http://127.0.0.1:8788/stats | jq '.compressions_by_strategy'
|
||||
# {
|
||||
# "intelligent_context": 12453,
|
||||
# "smart_crusher": 487,
|
||||
# "search": 312,
|
||||
# "diff": 28,
|
||||
# "code": 0, # ← never fires; safe to defer porting
|
||||
# ...
|
||||
# }
|
||||
|
||||
# Per-transform timing (avg/max/count by transform name)
|
||||
curl -s http://127.0.0.1:8788/stats | jq '.pipeline_timing'
|
||||
|
||||
# Token savings attributable to each strategy
|
||||
curl -s http://127.0.0.1:8788/stats | jq '.tokens_saved_by_strategy'
|
||||
```
|
||||
|
||||
This is the data the audit-cleanup PR (2026-04-30) recommended for
|
||||
prioritizing the next Python → Rust port. Strategies with zero or
|
||||
near-zero invocations are deferral candidates; strategies on the hot
|
||||
path are porting candidates regardless of LOC count.
|
||||
|
||||
### Reserved paths
|
||||
|
||||
`/healthz` and `/healthz/upstream` are intercepted by the Rust proxy and
|
||||
**not** forwarded. Operators must not name a real upstream route either of
|
||||
these. Everything else is a catch-all forward.
|
||||
|
||||
## Maturin + Python wiring
|
||||
|
||||
`headroom-py` is a PyO3 cdylib that exposes `headroom._core` in Python. The
|
||||
`extension-module` feature is opt-in so plain `cargo build --workspace` does
|
||||
not try to link against `libpython` on systems that don't have it.
|
||||
|
||||
### First-time setup (clean venv recommended)
|
||||
|
||||
```bash
|
||||
python3.11 -m venv /tmp/hr-rust-venv
|
||||
source /tmp/hr-rust-venv/bin/activate
|
||||
pip install maturin
|
||||
cd crates/headroom-py
|
||||
maturin develop # editable dev build, installs headroom._core
|
||||
cd /tmp # IMPORTANT: step out of the repo root first
|
||||
python -c "from headroom._core import hello; print(hello())"
|
||||
# => headroom-core
|
||||
```
|
||||
|
||||
> Why `cd /tmp`? The repo root also contains the Python `headroom/` package.
|
||||
> Running the smoke import from the repo root makes Python resolve `headroom`
|
||||
> to `./headroom/__init__.py` (the full SDK, which pulls in heavy deps) instead
|
||||
> of the lightweight namespace package installed by maturin. Tests should
|
||||
> either run outside the repo root, or ensure `headroom` is installed into
|
||||
> the same venv (then the maturin-installed `_core.so` lands alongside it and
|
||||
> both imports resolve).
|
||||
|
||||
### Release wheels
|
||||
|
||||
```bash
|
||||
make build-wheel
|
||||
# wheels land under target/wheels/
|
||||
```
|
||||
|
||||
CI (`.github/workflows/rust.yml`) builds linux-x86_64, macos-arm64, and
|
||||
macos-x86_64 wheels via `PyO3/maturin-action` and uploads them as artifacts.
|
||||
|
||||
## Parity harness
|
||||
|
||||
`crates/headroom-parity` owns the Rust-vs-Python oracle:
|
||||
|
||||
- JSON fixtures under `tests/parity/fixtures/<transform>/` (schema:
|
||||
`{ transform, input, config, output, recorded_at, input_sha256 }`).
|
||||
- `TransformComparator` trait — one impl per transform. Phase 0 stubs return
|
||||
`Err(...)`; the harness flags those as `Skipped`, not panics.
|
||||
- `parity-run` CLI: `cargo run -p headroom-parity -- run [--only TRANSFORM]`.
|
||||
- Unit tests in `crates/headroom-parity/src/lib.rs` include a **negative
|
||||
test** (`harness_reports_diff_for_divergent_comparator`) proving the
|
||||
harness detects mismatched output before any real port lands.
|
||||
|
||||
### Recording fresh fixtures
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # the main Python SDK venv
|
||||
python scripts/record_fixtures.py # uses tests/parity/recorder.py
|
||||
ls tests/parity/fixtures/*/ | sort | uniq -c
|
||||
```
|
||||
|
||||
The recorder monkey-patches the in-process transform classes (see
|
||||
`record_all()` in `tests/parity/recorder.py`). It does **not** modify any
|
||||
file under `headroom/`.
|
||||
|
||||
## Known regressions in retired-Python components
|
||||
|
||||
The Stage 3b/3c.1b retirements deleted Python source for `DiffCompressor`
|
||||
and `SmartCrusher` and replaced them with PyO3-delegating shims. The
|
||||
2026-04-28 audit found that the retirements shipped with subsystems
|
||||
silently disconnected. This section tracks each gap and its disposition
|
||||
so they don't regress further or get forgotten.
|
||||
|
||||
### SmartCrusher
|
||||
|
||||
| Subsystem | State | Tracked by |
|
||||
|---|---|---|
|
||||
| TOIN learning loop | **Re-attached 2026-04-28.** Shim's `crush()` and `_smart_crush_content()` now call `toin.record_compression()` after a real compression. Filtered on `strategy != "passthrough"` to ignore JSON re-canonicalization. Best-effort: TOIN failures are logged at debug level and don't break compression. | `tests/test_smart_crusher_toin_attachment.py` |
|
||||
| CCR marker emission knob | **Honored end-to-end 2026-04-29.** New `enable_ccr_marker: bool` field on Rust `SmartCrusherConfig`; `crush_array` checks it before emitting the `<<ccr:HASH>>` marker text and the CCR store write. Python shim flips it from `ccr_config.enabled and ccr_config.inject_retrieval_marker` — both flags collapse to the same Rust gate, since storing payloads under either off-switch makes no sense. Scope: gates only the row-drop sentinel path; Stage-3c.2 opaque-string CCR substitutions still emit always (no Python equivalent, no production caller asks for suppression). | `tests/test_smart_crusher_toin_attachment.py` + `crates/headroom-core/.../crusher.rs::tests::enable_ccr_marker_*` |
|
||||
| Custom relevance scorer | **Closed (fail-loud) 2026-04-29.** `relevance_config` and `scorer` constructor args remain in the signature for source compat, but the shim raises `NotImplementedError` when either is non-None — silently dropping a user-supplied scorer is a textbook silent-fallback bug. Full plumbing waits on Stage-3c.2's relevance-crate Python bridge. | `tests/test_smart_crusher_toin_attachment.py::test_custom_*_arg_raises_not_implemented` |
|
||||
| Per-tool TOIN learning hook | **Re-attached partially.** `_smart_crush_content` accepts `tool_name` and now threads it into the TOIN record. The hook is best-effort — it improves `query_context` aggregation but doesn't drive per-tool overrides yet. | `tests/test_smart_crusher_toin_attachment.py::test_smart_crush_content_records_to_toin` |
|
||||
|
||||
### DiffCompressor
|
||||
|
||||
| Subsystem | State |
|
||||
|---|---|
|
||||
| Adaptive context windows | Honored byte-for-byte (parity fixture-locked). |
|
||||
| TOIN integration | Never had one — DiffCompressor records via `_record_to_toin` in ContentRouter, which already runs for non-SmartCrusher strategies. No regression. |
|
||||
|
||||
### Phase 3e.1 — `signals/` trait module + KeywordDetector (2026-04-29)
|
||||
|
||||
The Python `error_detection.py` regex registry was retired and reborn as a
|
||||
trait + tier system in `crates/headroom-core/src/signals/`. See
|
||||
`signals/README.md` for the full architecture; the highlights:
|
||||
|
||||
- **Per-granularity traits.** `LineImportanceDetector` ships today; future
|
||||
`ContentTypeDetector` and `ItemImportanceDetector<I>` will follow as their
|
||||
consumers get touched.
|
||||
- **`Tiered<T>` combinator.** Composition, not inheritance. Future ML
|
||||
detectors slot in as new tiers without changes to `KeywordDetector` or
|
||||
any caller.
|
||||
- **One concrete impl.** `KeywordDetector` (aho-corasick) is the only tier
|
||||
registered today. **No NoOp/stub impls** — per project no-silent-fallbacks
|
||||
rule, future tiers land with their real implementations.
|
||||
- **Bug fixes baked in.** `ERROR_KEYWORDS` regex now includes
|
||||
`timeout|abort|denied|rejected` (previously drifted from the keyword set);
|
||||
`token` dropped from `SECURITY_KEYWORDS` (false-positived on every LLM
|
||||
metric reference). Both fixed in the Python regex too via the shim that
|
||||
recompiles patterns from the Rust-exposed keyword tables.
|
||||
- **Companion canonical extension path.** `signals/README.md` documents
|
||||
the BGE classifier head — a 384-dim → 4-class softmax on top of the
|
||||
already-loaded `bge-small-en-v1.5` embedder — as the natural ML tier.
|
||||
Two alternatives kept open: distilled tinyBERT in ONNX, logistic
|
||||
regression on lexical features.
|
||||
|
||||
### Phase 3g (queued) — Compression Pipeline Formalization (issue #315)
|
||||
|
||||
Strategic decision 2026-04-29: after Phase 3e (compressor ports) and
|
||||
Phase 3f (Rust MCP scaffold) wrap, formalize the lossless-then-lossy-
|
||||
then-CCR ordering as a cross-cutting `CompressionPipeline` orchestrator
|
||||
+ `LosslessTransform` / `LossyTransform` traits in
|
||||
`crates/headroom-core/src/pipeline/`. Existing compressors get
|
||||
refactored as compositions of pluggable transforms. The crucial design
|
||||
choice — **parsers for structure, models at the prose/structure
|
||||
boundary** — is captured in issue #315 and
|
||||
`memory/project_lossless_first_pipeline.md`. Do NOT start coding before
|
||||
3e/3f finish.
|
||||
|
||||
### Watch list (potential regressions, not yet audited)
|
||||
|
||||
- `CCRConfig.enabled=False` end-to-end — **closed 2026-04-29**. Both `enabled=False` and `inject_retrieval_marker=False` collapse to the same Rust `enable_ccr_marker=False` gate (no marker, no store write). See the SmartCrusher table above.
|
||||
- `SmartCrusherConfig.use_feedback_hints=False` — config field is forwarded to Rust but its honoring inside the Rust crusher hasn't been verified against a parity fixture for the disabled path.
|
||||
|
||||
When any item above changes, update both this section and the test file. The shim's docstring also references this section — keep them aligned.
|
||||
|
||||
## Phase 0 Blockers
|
||||
|
||||
These are known limitations for Phase 0. They are tracked here so Phase 1
|
||||
doesn't rediscover them.
|
||||
|
||||
- **`cache_aligner` fixtures**: `CacheAligner.apply()` takes
|
||||
`(messages, tokenizer, **kwargs)` — a `Tokenizer` is provider-specific and
|
||||
its cheapest `NoopTokenCounter` / `TiktokenTokenCounter` construction still
|
||||
requires pulling `headroom.providers.*` which imports the full observability
|
||||
stack (opentelemetry, etc). The recorder records `cache_aligner` only if a
|
||||
usable tokenizer is cheaply available; otherwise it logs a blocker and
|
||||
skips. See `recorder.py::_build_cache_aligner_tokenizer`.
|
||||
- **`ccr` is not a single class**: The repo has `CCRToolInjector`,
|
||||
`CCRResponseHandler`, `CCRToolCall`, `CCRToolResult` etc. rather than a
|
||||
single `CCR` class. The recorder targets the encoder-style entry point
|
||||
most analogous to the Rust port (`CCRToolInjector.inject_tool` and
|
||||
`CCRResponseHandler.parse_response`). If Phase 1 wants a different split
|
||||
it should update `recorder.py::record_all` accordingly.
|
||||
- **Pre-commit hook noise**: `scripts/sync-plugin-versions.py` mutates
|
||||
`.claude-plugin/marketplace.json`, `.github/plugin/marketplace.json`, and
|
||||
`plugins/headroom-agent-hooks/**/plugin.json` on every commit. Those
|
||||
changes are harmless but each commit in Phase 0 picks them up. Phase 1
|
||||
does not need to do anything special — just let the hook run.
|
||||
- **`rust-toolchain.toml`** pins `channel = "stable"` rather than a specific
|
||||
version so CI picks up the same toolchain the local box uses. Tighten to a
|
||||
pinned version (e.g. `1.78`) once the port stabilizes.
|
||||
|
||||
## Multi-worker deployment — CCR fragmentation
|
||||
|
||||
**Status:** two persistent CCR backends are available. The single-`--workers`
|
||||
recommendation no longer applies once you select a persistent backend.
|
||||
|
||||
### Backend selection
|
||||
|
||||
`crates/headroom-core/src/ccr/backends/` ships three implementations of
|
||||
the `CcrStore` trait:
|
||||
|
||||
| Backend | When to use | Persistence | Multi-worker safe |
|
||||
| ---------------------- | ------------------------------------------- | ----------- | -------------------------- |
|
||||
| `InMemoryCcrStore` | Tests, single-worker prototyping | No | No |
|
||||
| `SqliteCcrStore` (default) | Single-instance prod / single-host fleet | Yes (file) | Yes (sticky session) |
|
||||
| `RedisCcrStore` (opt-in) | Multi-host / horizontally-scaled prod | Yes (Redis) | Yes (no stickiness needed) |
|
||||
|
||||
`backends::from_config` picks one at startup from the operator's
|
||||
`CcrBackendConfig`. **Init failures surface to the caller**
|
||||
(`feedback_no_silent_fallbacks.md`) — a misconfigured DB path or
|
||||
unreachable Redis URL aborts startup rather than silently degrading to
|
||||
in-memory.
|
||||
|
||||
### When does what work?
|
||||
|
||||
- **`SqliteCcrStore`** is the default for new deploys. The DB file lives
|
||||
on the local disk; multiple workers on the **same host** share it via
|
||||
SQLite's WAL-mode locking, so `--workers N` works as long as a sticky
|
||||
load balancer routes each session to the same host. Survives proxy
|
||||
restarts: a new worker that opens the same DB file recovers every
|
||||
in-flight `<<ccr:HASH>>` marker.
|
||||
- **`RedisCcrStore`** (cfg-gated behind the `redis` feature) is the
|
||||
drop-in for **horizontally-scaled** deployments. Every worker on
|
||||
every host hits the same Redis instance; no sticky session is
|
||||
required at any layer of the LB. Enable with `--features redis` in
|
||||
the proxy crate's Cargo build.
|
||||
- **`InMemoryCcrStore`** is fine for tests and single-worker
|
||||
development. Production deployments using it lose every
|
||||
`<<ccr:HASH>>` marker on restart and fragment across workers — keep
|
||||
it confined to local boxes.
|
||||
|
||||
### What goes wrong with the in-memory backend on `--workers N > 1`
|
||||
|
||||
Each uvicorn worker is a separate Python process. The following state is
|
||||
fragmented across workers:
|
||||
|
||||
1. **Python `CompressionStore`** — defaults to `InMemoryBackend` (per-process)
|
||||
when `HEADROOM_CCR_BACKEND` is unset. Each worker has its own singleton; CCR
|
||||
markers written on worker A are invisible to worker B. Set
|
||||
`HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store.
|
||||
2. **`HeadroomProxy._compression_caches`** (`headroom/proxy/server.py`)
|
||||
— per-session `CompressionCache` dict (instance var, always per-worker).
|
||||
3. **`HeadroomProxy.session_tracker_store`** — per-session prefix-tracker
|
||||
state derived from Anthropic's `cache_read_input_tokens` responses
|
||||
(instance var, always per-worker).
|
||||
4. **TOIN learner state** — writes snapshots to `~/.headroom/toin.json` but
|
||||
keeps per-process in-memory state; pattern statistics on one worker are not
|
||||
visible to others until the next disk flush.
|
||||
|
||||
When uvicorn round-robins requests across workers, a session whose
|
||||
turn-1 landed on worker A may have turn-2 land on worker B. Worker B has
|
||||
zero knowledge of what worker A did, the `<<ccr:HASH>>` marker resolves
|
||||
to `None`, and the model sees an opaque directive it can't act on.
|
||||
Switching to `SqliteCcrStore` (default) or `RedisCcrStore` resolves the
|
||||
CCR fragmentation; a sticky-session load balancer resolves all of them.
|
||||
|
||||
### Detecting it in the wild
|
||||
|
||||
The proxy emits a `WARNING`-level log line on startup when `--workers N > 1`.
|
||||
When `HEADROOM_CCR_BACKEND` is unset (default InMemoryBackend), the warning
|
||||
includes CCR retrieval failures and suggests setting `HEADROOM_CCR_BACKEND=sqlite`.
|
||||
When a cross-worker backend is already configured, the warning covers only the
|
||||
remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker).
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.27.x (latest) | :white_check_mark: |
|
||||
| < 0.27.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
|
||||
|
||||
### How to Report
|
||||
|
||||
**Please DO NOT open a public GitHub issue for security vulnerabilities.**
|
||||
|
||||
Instead, please email us at: **security@headroomlabs.ai**
|
||||
|
||||
Include the following information:
|
||||
- Type of vulnerability (e.g., injection, data exposure, authentication bypass)
|
||||
- Full path of the affected source file(s)
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact assessment
|
||||
|
||||
### What to Expect
|
||||
|
||||
1. **Acknowledgment**: We will acknowledge receipt within 48 hours
|
||||
2. **Assessment**: We will assess the vulnerability and determine its severity
|
||||
3. **Updates**: We will keep you informed of our progress
|
||||
4. **Resolution**: We aim to resolve critical issues within 7 days
|
||||
5. **Credit**: With your permission, we will credit you in the security advisory
|
||||
|
||||
### Security Best Practices for Users
|
||||
|
||||
When using Headroom:
|
||||
|
||||
1. **API Keys**: Never commit API keys. Use environment variables.
|
||||
2. **Proxy Exposure**: Don't expose the proxy server to the public internet without authentication
|
||||
3. **Log Files**: Be aware that request logs may contain sensitive information
|
||||
4. **Budget Limits**: Set budget limits to prevent unexpected costs
|
||||
|
||||
### Scope
|
||||
|
||||
The following are in scope for security reports:
|
||||
- Headroom Python package (`pip install headroom-ai`)
|
||||
- Headroom proxy server
|
||||
- Official integrations (LangChain, Agno, Strands, LiteLLM, Vercel AI SDK, Anthropic/OpenAI SDK wrappers, MCP)
|
||||
|
||||
The following are out of scope:
|
||||
- Third-party integrations not maintained by us
|
||||
- Issues in dependencies (report these to the upstream project)
|
||||
- Social engineering attacks
|
||||
|
||||
## Security Features
|
||||
|
||||
Headroom includes several security features:
|
||||
|
||||
- **No credential storage**: We never store or log API keys
|
||||
- **Passthrough mode**: Sensitive content passes through unchanged by default
|
||||
- **Input validation**: All inputs are validated before processing
|
||||
- **Safe defaults**: Security-conscious defaults out of the box
|
||||
|
||||
Thank you for helping keep Headroom and its users safe!
|
||||
@@ -0,0 +1,153 @@
|
||||
# Testing: GitHub Copilot subscription mode (`headroom wrap copilot --subscription`)
|
||||
|
||||
This is an **experimental** feature and we need help verifying it on **Linux and
|
||||
Windows**. It already works on macOS; the cross-platform gap is small and
|
||||
specific (see [Status](#status)). If you have a GitHub Copilot subscription and
|
||||
10 minutes, please run one of the flows below and
|
||||
[file a report](https://github.com/chopratejas/headroom/issues/new?template=copilot-subscription-test-report.md).
|
||||
|
||||
> ⚠️ This is experimental, and it reads your Copilot login token + routes your
|
||||
> Copilot CLI traffic through a local Headroom proxy. Only run it if you're
|
||||
> comfortable with that. The branch is open for inspection.
|
||||
|
||||
## What it does (and what "subscription" means here)
|
||||
|
||||
Normally `headroom wrap copilot` is **BYOK** — you bring an Anthropic/OpenAI API
|
||||
key and pay that vendor. `--subscription` is different: it lets you use the
|
||||
**Copilot seat you already pay GitHub for**, with **no separate API key**, while
|
||||
still routing through Headroom so your context gets compressed.
|
||||
|
||||
Mechanically: the Copilot CLI's only interposition hook is its provider-override
|
||||
(the "BYOK transport"), so Headroom uses that knob but supplies **your
|
||||
subscription token** and points back at **GitHub's own Copilot API**. So the CLI
|
||||
may print "BYOK" and require an explicit `--model`, but you are **not** paying a
|
||||
third party — it's your subscription, just compressed. (Proof it's working: the
|
||||
proxy forwards to GitHub's Copilot API — `https://api.githubcopilot.com` by
|
||||
default — with your token.)
|
||||
|
||||
## API host & Enterprise / data-residency
|
||||
|
||||
Headroom routes wrapped Copilot traffic to GitHub's **generic public host**,
|
||||
`https://api.githubcopilot.com`, for both `--subscription` and the implicit
|
||||
OAuth path. That host serves the full model set (including newer models on the
|
||||
responses API) and matches the routing that worked before 0.23.
|
||||
|
||||
Headroom deliberately does **not** auto-select a per-account host from
|
||||
`/copilot_internal/user`. That endpoint advertises a segmented host (e.g.
|
||||
`api.individual.githubcopilot.com`) that does **not** serve newer models on the
|
||||
responses API and is not the host the official Copilot client routes with — using
|
||||
it regressed `headroom wrap copilot` after 0.22.4
|
||||
([#610](https://github.com/chopratejas/headroom/issues/610)).
|
||||
|
||||
**Enterprise / data-residency:** if your organization is provisioned on a
|
||||
dedicated Copilot API host (GitHub Enterprise Cloud with data residency, or an
|
||||
egress proxy), pin it explicitly — the override flows through both
|
||||
`--subscription` and OAuth, and onward through the proxy to the upstream request:
|
||||
|
||||
```bash
|
||||
export GITHUB_COPILOT_API_URL=https://api.<your-host>.githubcopilot.com
|
||||
headroom wrap copilot --subscription -- --model gpt-5.4
|
||||
```
|
||||
|
||||
If you operate such an environment and would like Headroom to **auto-detect** the
|
||||
correct host instead of pinning it, please [open an issue](https://github.com/chopratejas/headroom/issues/new) —
|
||||
the intended path is to resolve it from GitHub's token-exchange endpoint (the
|
||||
source the official Copilot client uses), and we'd want to validate it against a
|
||||
real enterprise tenant.
|
||||
|
||||
## Status
|
||||
|
||||
| Platform | Mechanism (compress + forward) | Token **auto-discovery** from the OS secret store |
|
||||
|----------|:---:|:---:|
|
||||
| macOS (Keychain) | ✅ verified | ✅ verified (`copilot-cli`) |
|
||||
| Linux (`secret-tool`/libsecret) | ✅ expected | ❓ **needs testing** |
|
||||
| Windows (Credential Manager) | ✅ expected | ❓ **needs testing** |
|
||||
| Any OS via `GITHUB_COPILOT_TOKEN` env var | ✅ verified by tests | n/a (bypasses discovery) |
|
||||
|
||||
The two things we want to learn:
|
||||
1. **Does it work end to end on your OS?**
|
||||
2. **Does it find your Copilot token automatically**, or do you have to set
|
||||
`GITHUB_COPILOT_TOKEN`? If it can't find it, we need the **storage schema**
|
||||
(see each flow) so we can fix auto-discovery.
|
||||
|
||||
## Prerequisites (all platforms)
|
||||
|
||||
1. A **GitHub Copilot subscription**.
|
||||
2. The **GitHub Copilot CLI**: `npm install -g @github/copilot`
|
||||
3. **Log in once**: run `copilot`, complete the device-code login in your
|
||||
browser, then type `/exit`.
|
||||
|
||||
---
|
||||
|
||||
## Linux — the flow we most need (tests auto-discovery)
|
||||
|
||||
Auto-discovery only works with a **host-native** install (a container can't read
|
||||
your host secret store). Linux has prebuilt wheels, so:
|
||||
|
||||
```bash
|
||||
pipx install --pip-args='--pre' headroom-ai # or: pip install --pre headroom-ai
|
||||
# (no separate API key needed — that's the point)
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
|
||||
```
|
||||
|
||||
- **If it prints `HEADROOM_OK`** → auto-discovery works on your Linux. 🎉 Report success.
|
||||
- **If it errors with "no reusable bearer token"** → discovery missed your token. Please grab the **schema** so we can fix it (redact the secret), then confirm the mechanism works via the env var:
|
||||
```bash
|
||||
secret-tool search --all 2>/dev/null | sed -E 's/^secret = .*/secret = <redacted>/'
|
||||
# then retry, supplying the token explicitly:
|
||||
GITHUB_COPILOT_TOKEN='<your-token>' headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"
|
||||
```
|
||||
Report the `attribute.*` lines from `secret-tool` and whether the env-var retry worked.
|
||||
|
||||
---
|
||||
|
||||
## Windows
|
||||
|
||||
There is **no native Windows wheel yet**, so pick one:
|
||||
|
||||
**A. Mechanism test (easiest — Docker Desktop or WSL2):**
|
||||
```powershell
|
||||
$env:HEADROOM_DOCKER_IMAGE = "ghcr.io/chopratejas/headroom:<branch-tag>" # ask the maintainer for the tag
|
||||
# run the Docker-native installer (scripts/install.ps1), then:
|
||||
$env:GITHUB_COPILOT_TOKEN = "<your-token>"
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"
|
||||
```
|
||||
Report whether it prints `HEADROOM_OK`.
|
||||
|
||||
**B. Native auto-discovery schema (even without a working install):** after
|
||||
`copilot` login, tell us where Windows stored the token:
|
||||
```cmd
|
||||
cmd /c "cmdkey /list"
|
||||
```
|
||||
Report the `Target:` line that looks Copilot-related (it shows the target name,
|
||||
not the secret). That single fact lets us make native Windows discovery work.
|
||||
|
||||
> Native Windows auto-discovery becomes fully testable once we add a Windows
|
||||
> wheel to the build matrix — tracked separately.
|
||||
|
||||
---
|
||||
|
||||
## macOS (already proven — a second data point still helps)
|
||||
|
||||
```bash
|
||||
pipx install --pip-args='--pre' headroom-ai
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
|
||||
```
|
||||
Schema, for reference: Keychain generic password, service `copilot-cli`
|
||||
(`security find-generic-password -s copilot-cli -w`).
|
||||
|
||||
---
|
||||
|
||||
## What to report
|
||||
|
||||
Please open a
|
||||
[Copilot subscription test report](https://github.com/chopratejas/headroom/issues/new?template=copilot-subscription-test-report.md)
|
||||
with:
|
||||
|
||||
- **OS + version** and **how you installed** (pipx/pip wheel, Docker, source).
|
||||
- Was plain `copilot` logged in?
|
||||
- Did `wrap copilot --subscription` print **`HEADROOM_OK`**? Paste any error.
|
||||
- Did it work **without** setting `GITHUB_COPILOT_TOKEN` (auto-discovery), or
|
||||
only **with** it?
|
||||
- The **storage schema** if discovery failed (`secret-tool search --all` /
|
||||
`cmdkey /list`), with the secret redacted.
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Headroom SDK Benchmark Suite.
|
||||
|
||||
This package provides performance benchmarks for Headroom transforms and relevance
|
||||
scorers. Benchmarks use pytest-benchmark for accurate timing measurements.
|
||||
|
||||
Usage:
|
||||
# Run all benchmarks
|
||||
pytest benchmarks/ --benchmark-only
|
||||
|
||||
# Run specific suite
|
||||
pytest benchmarks/bench_transforms.py --benchmark-only
|
||||
|
||||
# Generate comparison report
|
||||
python benchmarks/run_benchmarks.py --suite all --output report.md
|
||||
|
||||
Performance Targets:
|
||||
- SmartCrusher: < 10ms for 1000 items
|
||||
- CacheAligner: < 1ms for date extraction
|
||||
- BM25Scorer: < 1ms for 100 items
|
||||
- HybridScorer: < 50ms for 100 items (with embeddings)
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
from .scenarios.conversations import (
|
||||
generate_agentic_conversation,
|
||||
generate_rag_conversation,
|
||||
)
|
||||
from .scenarios.tool_outputs import (
|
||||
generate_api_responses,
|
||||
generate_database_rows,
|
||||
generate_log_entries,
|
||||
generate_search_results,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Data generators
|
||||
"generate_search_results",
|
||||
"generate_log_entries",
|
||||
"generate_api_responses",
|
||||
"generate_database_rows",
|
||||
# Conversation generators
|
||||
"generate_agentic_conversation",
|
||||
"generate_rag_conversation",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,804 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent Cost Crisis Benchmark - The Compelling Story
|
||||
|
||||
This benchmark demonstrates WHY Headroom matters by showing:
|
||||
|
||||
1. THE PROBLEM: Context explosion in real-world agent workloads
|
||||
- Tokens grow exponentially with conversation length
|
||||
- Tool outputs dominate context (often 70%+ of tokens)
|
||||
- Dynamic content breaks cache efficiency
|
||||
|
||||
2. THE SOLUTION: Headroom's impact on real workloads
|
||||
- Token reduction from SmartCrusher (50-80% on tool outputs)
|
||||
- Cache alignment improvement (10x+ potential savings)
|
||||
- Context windowing (stay within limits without losing info)
|
||||
|
||||
3. THE PROOF: Quality preservation
|
||||
- Critical information retained (errors, anomalies, relevant items)
|
||||
- Agent task completion unaffected
|
||||
- Information retrieval accuracy maintained
|
||||
|
||||
Usage:
|
||||
python benchmarks/agent_cost_benchmark.py
|
||||
python benchmarks/agent_cost_benchmark.py --format markdown > BENCHMARK.md
|
||||
python benchmarks/agent_cost_benchmark.py --scenario coding-agent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Benchmark scenario imports
|
||||
from benchmarks.scenarios.conversations import (
|
||||
generate_agentic_conversation,
|
||||
generate_rag_conversation,
|
||||
)
|
||||
from benchmarks.scenarios.tool_outputs import (
|
||||
generate_log_entries,
|
||||
generate_search_results,
|
||||
)
|
||||
|
||||
# Headroom imports
|
||||
from headroom.transforms.smart_crusher import SmartCrusherConfig, smart_crush_tool_output
|
||||
|
||||
# =============================================================================
|
||||
# PRICING DATA (as of 2025)
|
||||
# =============================================================================
|
||||
|
||||
PRICING = {
|
||||
# Anthropic Claude 3.5 Sonnet
|
||||
"claude-3.5-sonnet": {
|
||||
"input": 3.00 / 1_000_000, # $3 per 1M tokens
|
||||
"output": 15.00 / 1_000_000, # $15 per 1M tokens
|
||||
"cached_input": 0.30 / 1_000_000, # 90% discount on cache hit
|
||||
"cache_write": 3.75 / 1_000_000, # 25% premium to write cache
|
||||
},
|
||||
# OpenAI GPT-4o
|
||||
"gpt-4o": {
|
||||
"input": 2.50 / 1_000_000,
|
||||
"output": 10.00 / 1_000_000,
|
||||
"cached_input": 1.25 / 1_000_000, # 50% discount
|
||||
},
|
||||
# Google Gemini 1.5 Pro
|
||||
"gemini-1.5-pro": {
|
||||
"input": 1.25 / 1_000_000,
|
||||
"output": 5.00 / 1_000_000,
|
||||
"cached_input": 0.3125 / 1_000_000, # 75% discount
|
||||
},
|
||||
}
|
||||
|
||||
# Approximate tokens per character (GPT-4 tokenizer average)
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostAnalysis:
|
||||
"""Cost analysis for a workload."""
|
||||
|
||||
tokens_input: int = 0
|
||||
tokens_output: int = 0
|
||||
tokens_cached: int = 0
|
||||
|
||||
cost_baseline: float = 0.0
|
||||
cost_optimized: float = 0.0
|
||||
cost_with_cache: float = 0.0
|
||||
|
||||
savings_from_compression: float = 0.0
|
||||
savings_from_caching: float = 0.0
|
||||
total_savings_percent: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""Result from a single benchmark scenario."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
# Token metrics
|
||||
tokens_original: int = 0
|
||||
tokens_optimized: int = 0
|
||||
compression_ratio: float = 0.0
|
||||
|
||||
# Cache metrics
|
||||
cache_hit_rate_baseline: float = 0.0
|
||||
cache_hit_rate_optimized: float = 0.0
|
||||
|
||||
# Quality metrics
|
||||
critical_items_retained: int = 0
|
||||
critical_items_total: int = 0
|
||||
retention_rate: float = 0.0
|
||||
|
||||
# Cost analysis
|
||||
cost_analysis: CostAnalysis = field(default_factory=CostAnalysis)
|
||||
|
||||
# Performance
|
||||
optimization_latency_ms: float = 0.0
|
||||
|
||||
# Details
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCENARIO 1: Coding Agent Context Explosion
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def benchmark_coding_agent_explosion() -> BenchmarkResult:
|
||||
"""
|
||||
Simulate a Claude Code / Cursor style coding agent session.
|
||||
|
||||
Shows how context explodes as the agent:
|
||||
- Searches codebase (100s of file snippets)
|
||||
- Reads documentation (large text blocks)
|
||||
- Makes tool calls (grep, find, read)
|
||||
- Accumulates conversation history
|
||||
"""
|
||||
result = BenchmarkResult(
|
||||
name="Coding Agent Context Explosion",
|
||||
description="50-turn coding session with file search, grep, and documentation lookups",
|
||||
)
|
||||
|
||||
# Generate realistic coding agent conversation
|
||||
messages = generate_agentic_conversation(
|
||||
turns=50,
|
||||
tool_calls_per_turn=2,
|
||||
items_per_tool_response=100, # 100 search results per tool call
|
||||
)
|
||||
|
||||
# Calculate original tokens
|
||||
original_content = json.dumps(messages)
|
||||
result.tokens_original = len(original_content) // CHARS_PER_TOKEN
|
||||
|
||||
# Apply Headroom transforms using convenience function
|
||||
config = SmartCrusherConfig(max_items_after_crush=20)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
optimized_messages = []
|
||||
critical_retained = 0
|
||||
critical_total = 0
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
# Parse tool content as JSON array
|
||||
try:
|
||||
original_content = msg.get("content", "[]")
|
||||
content = json.loads(original_content)
|
||||
if isinstance(content, list) and len(content) > 10:
|
||||
# Count critical items (errors, high-relevance)
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("error") or item.get("status") == "failed":
|
||||
critical_total += 1
|
||||
if item.get("is_needle"):
|
||||
critical_total += 1
|
||||
|
||||
# Compress with SmartCrusher convenience function
|
||||
compressed_str, was_modified, _ = smart_crush_tool_output(
|
||||
original_content, config
|
||||
)
|
||||
|
||||
if was_modified:
|
||||
compressed = json.loads(compressed_str)
|
||||
# Count retained critical items
|
||||
for item in compressed:
|
||||
if isinstance(item, dict):
|
||||
if item.get("error") or item.get("status") == "failed":
|
||||
critical_retained += 1
|
||||
if item.get("is_needle"):
|
||||
critical_retained += 1
|
||||
|
||||
msg = {**msg, "content": compressed_str}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
optimized_messages.append(msg)
|
||||
|
||||
result.optimization_latency_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Calculate optimized tokens
|
||||
optimized_content = json.dumps(optimized_messages)
|
||||
result.tokens_optimized = len(optimized_content) // CHARS_PER_TOKEN
|
||||
|
||||
# Calculate metrics
|
||||
result.compression_ratio = 1 - (result.tokens_optimized / result.tokens_original)
|
||||
result.critical_items_total = critical_total
|
||||
result.critical_items_retained = critical_retained
|
||||
result.retention_rate = critical_retained / critical_total if critical_total > 0 else 1.0
|
||||
|
||||
# Cost analysis (using Claude 3.5 Sonnet pricing)
|
||||
pricing = PRICING["claude-3.5-sonnet"]
|
||||
result.cost_analysis = CostAnalysis(
|
||||
tokens_input=result.tokens_original,
|
||||
cost_baseline=result.tokens_original * pricing["input"],
|
||||
cost_optimized=result.tokens_optimized * pricing["input"],
|
||||
savings_from_compression=(result.tokens_original - result.tokens_optimized)
|
||||
* pricing["input"],
|
||||
)
|
||||
result.cost_analysis.total_savings_percent = result.compression_ratio * 100
|
||||
|
||||
result.details = {
|
||||
"turns": 50,
|
||||
"tool_calls": 100,
|
||||
"items_per_response": 100,
|
||||
"items_after_compression": 20,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCENARIO 2: Cache Alignment Impact
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def benchmark_cache_alignment() -> BenchmarkResult:
|
||||
"""
|
||||
Show how dynamic content breaks caching and how CacheAligner fixes it.
|
||||
|
||||
Simulates 100 requests with same base prompt but different dates.
|
||||
Without alignment: 0% cache hits
|
||||
With alignment: 90%+ cache hits
|
||||
"""
|
||||
from headroom.cache import DetectorConfig, DynamicContentDetector
|
||||
|
||||
result = BenchmarkResult(
|
||||
name="Cache Alignment Impact",
|
||||
description="100 requests with dynamic dates - cache hit improvement",
|
||||
)
|
||||
|
||||
# Base system prompt with dynamic date
|
||||
base_prompt = """You are Claude, an AI assistant by Anthropic.
|
||||
|
||||
Today is {date}.
|
||||
Current time: {time}.
|
||||
|
||||
Session ID: {session_id}
|
||||
Request ID: {request_id}
|
||||
|
||||
You are a helpful coding assistant. Follow these guidelines:
|
||||
1. Write clean, readable code
|
||||
2. Add appropriate comments
|
||||
3. Handle errors gracefully
|
||||
4. Follow best practices
|
||||
|
||||
Be concise and helpful."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
# Use DynamicContentDetector to extract static content
|
||||
detector = DynamicContentDetector(DetectorConfig(tiers=["regex"]))
|
||||
|
||||
# Simulate 100 requests over a day
|
||||
prompts_original = []
|
||||
prompts_aligned = []
|
||||
|
||||
base_date = datetime.datetime(2025, 1, 15, 9, 0, 0)
|
||||
|
||||
for i in range(100):
|
||||
# Each request has different timestamp
|
||||
request_time = base_date + datetime.timedelta(minutes=i * 5)
|
||||
|
||||
prompt = base_prompt.format(
|
||||
date=request_time.strftime("%A, %B %d, %Y"),
|
||||
time=request_time.strftime("%I:%M %p"),
|
||||
session_id=f"sess_{uuid.uuid4().hex[:24]}",
|
||||
request_id=f"req_{uuid.uuid4().hex[:24]}",
|
||||
)
|
||||
prompts_original.append(prompt)
|
||||
|
||||
# Extract static content for cache alignment
|
||||
detection_result = detector.detect(prompt)
|
||||
prompts_aligned.append(detection_result.static_content)
|
||||
|
||||
# Calculate cache hits
|
||||
# Baseline: all prompts are different (dynamic dates)
|
||||
unique_original = len(set(prompts_original))
|
||||
cache_hits_baseline = 100 - unique_original
|
||||
|
||||
# Aligned: static prefixes should be identical
|
||||
unique_aligned = len(set(prompts_aligned))
|
||||
cache_hits_aligned = 100 - unique_aligned
|
||||
|
||||
result.cache_hit_rate_baseline = cache_hits_baseline / 100
|
||||
result.cache_hit_rate_optimized = cache_hits_aligned / 100
|
||||
|
||||
# Token calculation
|
||||
result.tokens_original = sum(len(p) // CHARS_PER_TOKEN for p in prompts_original)
|
||||
|
||||
# Cost analysis with caching
|
||||
pricing = PRICING["claude-3.5-sonnet"]
|
||||
tokens_per_request = len(prompts_original[0]) // CHARS_PER_TOKEN
|
||||
|
||||
# Baseline: pay full price every time (no cache hits)
|
||||
cost_baseline = 100 * tokens_per_request * pricing["input"]
|
||||
|
||||
# Optimized: first request is cache write, rest are cache hits
|
||||
first_request_cost = tokens_per_request * pricing["cache_write"]
|
||||
cached_requests_cost = 99 * tokens_per_request * pricing["cached_input"]
|
||||
cost_optimized = first_request_cost + cached_requests_cost
|
||||
|
||||
result.cost_analysis = CostAnalysis(
|
||||
tokens_input=result.tokens_original,
|
||||
cost_baseline=cost_baseline,
|
||||
cost_with_cache=cost_optimized,
|
||||
savings_from_caching=cost_baseline - cost_optimized,
|
||||
total_savings_percent=((cost_baseline - cost_optimized) / cost_baseline) * 100,
|
||||
)
|
||||
|
||||
result.details = {
|
||||
"total_requests": 100,
|
||||
"unique_prompts_baseline": unique_original,
|
||||
"unique_prompts_aligned": unique_aligned,
|
||||
"cache_improvement_factor": f"{(cache_hits_aligned - cache_hits_baseline)}x",
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCENARIO 3: RAG Context Scaling
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def benchmark_rag_scaling() -> BenchmarkResult:
|
||||
"""
|
||||
Show how RAG context grows and how Headroom manages it.
|
||||
|
||||
Simulates large RAG context with multiple queries.
|
||||
"""
|
||||
result = BenchmarkResult(
|
||||
name="RAG Context Scaling", description="Large RAG context (~50K tokens) with compression"
|
||||
)
|
||||
|
||||
# Generate RAG conversation with ~50K tokens of context
|
||||
messages = generate_rag_conversation(
|
||||
context_tokens=50000,
|
||||
num_queries=10,
|
||||
)
|
||||
|
||||
original_content = json.dumps(messages)
|
||||
result.tokens_original = len(original_content) // CHARS_PER_TOKEN
|
||||
|
||||
# Apply transforms - compress tool outputs in messages
|
||||
config = SmartCrusherConfig(max_items_after_crush=10)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
# Compress tool outputs in messages
|
||||
optimized_messages = []
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
try:
|
||||
original_content_msg = msg.get("content", "[]")
|
||||
compressed_str, was_modified, _ = smart_crush_tool_output(
|
||||
original_content_msg, config
|
||||
)
|
||||
if was_modified:
|
||||
msg = {**msg, "content": compressed_str}
|
||||
except Exception:
|
||||
pass
|
||||
optimized_messages.append(msg)
|
||||
|
||||
result.optimization_latency_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
optimized_content = json.dumps(optimized_messages)
|
||||
result.tokens_optimized = len(optimized_content) // CHARS_PER_TOKEN
|
||||
result.compression_ratio = 1 - (result.tokens_optimized / result.tokens_original)
|
||||
|
||||
# Cost analysis
|
||||
pricing = PRICING["claude-3.5-sonnet"]
|
||||
result.cost_analysis = CostAnalysis(
|
||||
tokens_input=result.tokens_original,
|
||||
cost_baseline=result.tokens_original * pricing["input"],
|
||||
cost_optimized=result.tokens_optimized * pricing["input"],
|
||||
savings_from_compression=(result.tokens_original - result.tokens_optimized)
|
||||
* pricing["input"],
|
||||
total_savings_percent=result.compression_ratio * 100,
|
||||
)
|
||||
|
||||
result.details = {
|
||||
"context_tokens": 50000,
|
||||
"num_queries": 10,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCENARIO 4: Long-Running Agent Session
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def benchmark_conversation_scaling() -> list[BenchmarkResult]:
|
||||
"""
|
||||
Show how costs scale with conversation length.
|
||||
|
||||
Generates conversations of increasing length (10, 25, 50, 100, 200 turns)
|
||||
and shows the scaling curve with and without Headroom.
|
||||
"""
|
||||
results = []
|
||||
turn_counts = [10, 25, 50, 100, 200]
|
||||
|
||||
for turns in turn_counts:
|
||||
result = BenchmarkResult(
|
||||
name=f"Conversation Scaling ({turns} turns)",
|
||||
description=f"{turns}-turn agent conversation with tool calls",
|
||||
)
|
||||
|
||||
messages = generate_agentic_conversation(
|
||||
turns=turns,
|
||||
tool_calls_per_turn=1,
|
||||
items_per_tool_response=50,
|
||||
)
|
||||
|
||||
original_content = json.dumps(messages)
|
||||
result.tokens_original = len(original_content) // CHARS_PER_TOKEN
|
||||
|
||||
# Apply full optimization pipeline
|
||||
config = SmartCrusherConfig(max_items_after_crush=15)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
optimized = []
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
try:
|
||||
original_content = msg.get("content", "[]")
|
||||
content = json.loads(original_content)
|
||||
if isinstance(content, list) and len(content) > 15:
|
||||
compressed_str, was_modified, _ = smart_crush_tool_output(
|
||||
original_content, config
|
||||
)
|
||||
if was_modified:
|
||||
msg = {**msg, "content": compressed_str}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
optimized.append(msg)
|
||||
|
||||
result.optimization_latency_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
optimized_content = json.dumps(optimized)
|
||||
result.tokens_optimized = len(optimized_content) // CHARS_PER_TOKEN
|
||||
result.compression_ratio = 1 - (result.tokens_optimized / result.tokens_original)
|
||||
|
||||
pricing = PRICING["claude-3.5-sonnet"]
|
||||
result.cost_analysis = CostAnalysis(
|
||||
tokens_input=result.tokens_original,
|
||||
cost_baseline=result.tokens_original * pricing["input"],
|
||||
cost_optimized=result.tokens_optimized * pricing["input"],
|
||||
total_savings_percent=result.compression_ratio * 100,
|
||||
)
|
||||
|
||||
result.details = {"turns": turns}
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCENARIO 5: Quality Preservation Test
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def benchmark_quality_preservation() -> BenchmarkResult:
|
||||
"""
|
||||
Prove that compression doesn't lose critical information.
|
||||
|
||||
Generates data with known "needles" (errors, anomalies, high-relevance items)
|
||||
and verifies they survive compression.
|
||||
"""
|
||||
result = BenchmarkResult(
|
||||
name="Quality Preservation",
|
||||
description="Verify critical items (errors, anomalies) survive compression",
|
||||
)
|
||||
|
||||
# Generate test data with known needles
|
||||
search_results = generate_search_results(
|
||||
n=1000,
|
||||
include_uuid_needles=10,
|
||||
include_errors=20,
|
||||
)
|
||||
|
||||
log_entries = generate_log_entries(
|
||||
n=1000,
|
||||
include_errors=30,
|
||||
include_critical=5,
|
||||
)
|
||||
|
||||
# Count needles before compression
|
||||
needles_before = 0
|
||||
errors_before = 0
|
||||
|
||||
for item in search_results:
|
||||
if item.get("is_needle"):
|
||||
needles_before += 1
|
||||
if item.get("error"):
|
||||
errors_before += 1
|
||||
|
||||
for entry in log_entries:
|
||||
if entry.get("level") in ("ERROR", "CRITICAL"):
|
||||
errors_before += 1
|
||||
|
||||
# Compress using SmartCrusher convenience function
|
||||
config = SmartCrusherConfig(max_items_after_crush=50)
|
||||
|
||||
search_str = json.dumps(search_results)
|
||||
logs_str = json.dumps(log_entries)
|
||||
|
||||
compressed_search_str, _, _ = smart_crush_tool_output(search_str, config)
|
||||
compressed_logs_str, _, _ = smart_crush_tool_output(logs_str, config)
|
||||
|
||||
compressed_search = json.loads(compressed_search_str)
|
||||
compressed_logs = json.loads(compressed_logs_str)
|
||||
|
||||
# Count needles after compression
|
||||
needles_after = 0
|
||||
errors_after = 0
|
||||
|
||||
for item in compressed_search:
|
||||
if item.get("is_needle"):
|
||||
needles_after += 1
|
||||
if item.get("error"):
|
||||
errors_after += 1
|
||||
|
||||
for entry in compressed_logs:
|
||||
if entry.get("level") in ("ERROR", "CRITICAL"):
|
||||
errors_after += 1
|
||||
|
||||
result.critical_items_total = needles_before + errors_before
|
||||
result.critical_items_retained = needles_after + errors_after
|
||||
result.retention_rate = result.critical_items_retained / result.critical_items_total
|
||||
|
||||
result.tokens_original = (
|
||||
len(json.dumps(search_results)) + len(json.dumps(log_entries))
|
||||
) // CHARS_PER_TOKEN
|
||||
result.tokens_optimized = (
|
||||
len(json.dumps(compressed_search)) + len(json.dumps(compressed_logs))
|
||||
) // CHARS_PER_TOKEN
|
||||
result.compression_ratio = 1 - (result.tokens_optimized / result.tokens_original)
|
||||
|
||||
result.details = {
|
||||
"search_results_original": 1000,
|
||||
"search_results_compressed": len(compressed_search),
|
||||
"log_entries_original": 1000,
|
||||
"log_entries_compressed": len(compressed_logs),
|
||||
"needles_original": needles_before,
|
||||
"needles_retained": needles_after,
|
||||
"errors_original": errors_before,
|
||||
"errors_retained": errors_after,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REPORT GENERATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_report(results: list[BenchmarkResult], format: str = "terminal") -> str:
|
||||
"""Generate benchmark report in specified format."""
|
||||
|
||||
if format == "markdown":
|
||||
return _generate_markdown_report(results)
|
||||
else:
|
||||
return _generate_terminal_report(results)
|
||||
|
||||
|
||||
def _generate_terminal_report(results: list[BenchmarkResult]) -> str:
|
||||
"""Generate colorful terminal report."""
|
||||
lines = []
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 80)
|
||||
lines.append(" HEADROOM AGENT COST BENCHMARK")
|
||||
lines.append(" The Context Optimization Layer for LLM Applications")
|
||||
lines.append("=" * 80)
|
||||
|
||||
total_savings = 0.0
|
||||
total_baseline = 0.0
|
||||
|
||||
for result in results:
|
||||
lines.append("")
|
||||
lines.append(f"{'─' * 80}")
|
||||
lines.append(f" {result.name}")
|
||||
lines.append(f" {result.description}")
|
||||
lines.append(f"{'─' * 80}")
|
||||
|
||||
# Token metrics
|
||||
lines.append(f" Tokens (original): {result.tokens_original:>12,}")
|
||||
lines.append(f" Tokens (optimized): {result.tokens_optimized:>12,}")
|
||||
lines.append(f" Compression: {result.compression_ratio * 100:>11.1f}%")
|
||||
|
||||
# Cache metrics (if applicable)
|
||||
if result.cache_hit_rate_optimized > 0:
|
||||
lines.append(f" Cache Hit (before): {result.cache_hit_rate_baseline * 100:>11.1f}%")
|
||||
lines.append(f" Cache Hit (after): {result.cache_hit_rate_optimized * 100:>11.1f}%")
|
||||
|
||||
# Quality metrics (if applicable)
|
||||
if result.critical_items_total > 0:
|
||||
lines.append(
|
||||
f" Critical Items: {result.critical_items_retained}/{result.critical_items_total} retained"
|
||||
)
|
||||
lines.append(f" Retention Rate: {result.retention_rate * 100:>11.1f}%")
|
||||
|
||||
# Cost analysis
|
||||
ca = result.cost_analysis
|
||||
if ca.cost_baseline > 0:
|
||||
lines.append(f" Cost (baseline): ${ca.cost_baseline:>11.4f}")
|
||||
if ca.cost_optimized > 0:
|
||||
lines.append(f" Cost (optimized): ${ca.cost_optimized:>11.4f}")
|
||||
if ca.cost_with_cache > 0:
|
||||
lines.append(f" Cost (with cache): ${ca.cost_with_cache:>11.4f}")
|
||||
lines.append(f" Savings: {ca.total_savings_percent:>11.1f}%")
|
||||
|
||||
total_baseline += ca.cost_baseline
|
||||
if ca.cost_optimized > 0:
|
||||
total_savings += ca.cost_baseline - ca.cost_optimized
|
||||
elif ca.cost_with_cache > 0:
|
||||
total_savings += ca.cost_baseline - ca.cost_with_cache
|
||||
|
||||
# Performance
|
||||
if result.optimization_latency_ms > 0:
|
||||
lines.append(f" Optimization Time: {result.optimization_latency_ms:>11.2f}ms")
|
||||
|
||||
# Summary
|
||||
lines.append("")
|
||||
lines.append("=" * 80)
|
||||
lines.append(" SUMMARY")
|
||||
lines.append("=" * 80)
|
||||
if total_baseline > 0:
|
||||
lines.append(f" Total Baseline Cost: ${total_baseline:.4f}")
|
||||
lines.append(f" Total Savings: ${total_savings:.4f}")
|
||||
lines.append(f" Overall Reduction: {(total_savings / total_baseline) * 100:.1f}%")
|
||||
lines.append("")
|
||||
lines.append(" At 1M requests/month:")
|
||||
lines.append(f" Without Headroom: ${total_baseline * 1_000_000:.2f}")
|
||||
lines.append(f" With Headroom: ${(total_baseline - total_savings) * 1_000_000:.2f}")
|
||||
lines.append(f" Monthly Savings: ${total_savings * 1_000_000:.2f}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_markdown_report(results: list[BenchmarkResult]) -> str:
|
||||
"""Generate markdown report for documentation."""
|
||||
lines = []
|
||||
|
||||
lines.append("# Headroom Agent Cost Benchmark")
|
||||
lines.append("")
|
||||
lines.append("> The Context Optimization Layer for LLM Applications")
|
||||
lines.append("")
|
||||
lines.append("## Executive Summary")
|
||||
lines.append("")
|
||||
lines.append("This benchmark demonstrates Headroom's impact on real-world agent workloads:")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Impact |")
|
||||
lines.append("|--------|--------|")
|
||||
|
||||
# Calculate summary metrics
|
||||
total_compression = statistics.mean(
|
||||
[r.compression_ratio for r in results if r.compression_ratio > 0]
|
||||
)
|
||||
cache_improvement = next((r for r in results if r.cache_hit_rate_optimized > 0), None)
|
||||
quality_result = next((r for r in results if r.retention_rate > 0), None)
|
||||
|
||||
lines.append(f"| Token Reduction | **{total_compression * 100:.0f}%** average compression |")
|
||||
if cache_improvement:
|
||||
lines.append(
|
||||
f"| Cache Hit Rate | **{cache_improvement.cache_hit_rate_baseline * 100:.0f}% → {cache_improvement.cache_hit_rate_optimized * 100:.0f}%** |"
|
||||
)
|
||||
if quality_result:
|
||||
lines.append(
|
||||
f"| Quality Retention | **{quality_result.retention_rate * 100:.0f}%** critical items preserved |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Detailed results
|
||||
lines.append("## Detailed Results")
|
||||
lines.append("")
|
||||
|
||||
for result in results:
|
||||
lines.append(f"### {result.name}")
|
||||
lines.append("")
|
||||
lines.append(f"*{result.description}*")
|
||||
lines.append("")
|
||||
|
||||
lines.append("| Metric | Value |")
|
||||
lines.append("|--------|-------|")
|
||||
lines.append(f"| Original Tokens | {result.tokens_original:,} |")
|
||||
lines.append(f"| Optimized Tokens | {result.tokens_optimized:,} |")
|
||||
lines.append(f"| Compression | {result.compression_ratio * 100:.1f}% |")
|
||||
|
||||
if result.cost_analysis.total_savings_percent > 0:
|
||||
lines.append(f"| Cost Savings | {result.cost_analysis.total_savings_percent:.1f}% |")
|
||||
|
||||
if result.retention_rate > 0:
|
||||
lines.append(f"| Quality Retention | {result.retention_rate * 100:.1f}% |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Cost projection
|
||||
lines.append("## Cost Projection at Scale")
|
||||
lines.append("")
|
||||
lines.append("Based on Claude 3.5 Sonnet pricing ($3/1M input tokens):")
|
||||
lines.append("")
|
||||
lines.append("| Scale | Without Headroom | With Headroom | Monthly Savings |")
|
||||
lines.append("|-------|------------------|---------------|-----------------|")
|
||||
|
||||
base_cost_per_request = sum(r.cost_analysis.cost_baseline for r in results) / len(results)
|
||||
optimized_cost = sum(
|
||||
r.cost_analysis.cost_optimized
|
||||
or r.cost_analysis.cost_with_cache
|
||||
or r.cost_analysis.cost_baseline * 0.5
|
||||
for r in results
|
||||
) / len(results)
|
||||
|
||||
for scale, label in [(10_000, "10K"), (100_000, "100K"), (1_000_000, "1M")]:
|
||||
baseline = base_cost_per_request * scale
|
||||
optimized = optimized_cost * scale
|
||||
savings = baseline - optimized
|
||||
lines.append(
|
||||
f"| {label} requests/mo | ${baseline:,.0f} | ${optimized:,.0f} | ${savings:,.0f} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Headroom Agent Cost Benchmark")
|
||||
parser.add_argument("--format", choices=["terminal", "markdown"], default="terminal")
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=["all", "coding-agent", "cache", "rag", "scaling", "quality"],
|
||||
default="all",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
results = []
|
||||
|
||||
print("Running benchmarks...\n")
|
||||
|
||||
if args.scenario in ("all", "coding-agent"):
|
||||
print(" [1/5] Coding Agent Context Explosion...")
|
||||
results.append(benchmark_coding_agent_explosion())
|
||||
|
||||
if args.scenario in ("all", "cache"):
|
||||
print(" [2/5] Cache Alignment Impact...")
|
||||
results.append(benchmark_cache_alignment())
|
||||
|
||||
if args.scenario in ("all", "rag"):
|
||||
print(" [3/5] RAG Context Scaling...")
|
||||
results.append(benchmark_rag_scaling())
|
||||
|
||||
if args.scenario in ("all", "scaling"):
|
||||
print(" [4/5] Conversation Scaling...")
|
||||
scaling_results = benchmark_conversation_scaling()
|
||||
# Just add the 100-turn result to main results
|
||||
results.append(scaling_results[3]) # 100 turns
|
||||
|
||||
if args.scenario in ("all", "quality"):
|
||||
print(" [5/5] Quality Preservation...")
|
||||
results.append(benchmark_quality_preservation())
|
||||
|
||||
print("\n" + generate_report(results, args.format))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
"""Relevance scorer benchmarks for Headroom SDK.
|
||||
|
||||
This module contains performance benchmarks for relevance scorers:
|
||||
- BM25Scorer: Zero-dependency keyword matching
|
||||
- HybridScorer: BM25 + embedding fusion (with graceful fallback)
|
||||
|
||||
Performance Targets:
|
||||
BM25Scorer:
|
||||
- Single item: < 0.1ms
|
||||
- Batch 100: < 1ms
|
||||
- Batch 1000: < 10ms
|
||||
|
||||
HybridScorer (BM25 fallback):
|
||||
- Single item: < 0.2ms
|
||||
- Batch 100: < 2ms
|
||||
|
||||
HybridScorer (with embeddings):
|
||||
- Single item: < 5ms
|
||||
- Batch 100: < 50ms
|
||||
|
||||
Run with:
|
||||
pytest benchmarks/bench_relevance.py --benchmark-only -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _check_embedding_available() -> bool:
|
||||
"""Check if sentence-transformers is available for embedding tests."""
|
||||
try:
|
||||
import sentence_transformers # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
class TestBM25Benchmarks:
|
||||
"""Benchmarks for BM25 keyword relevance scorer.
|
||||
|
||||
BM25Scorer performs:
|
||||
- Text tokenization (regex-based)
|
||||
- IDF computation
|
||||
- BM25 score calculation
|
||||
- Long-token bonus (UUIDs, IDs)
|
||||
|
||||
Expected performance:
|
||||
- O(n*m) where n=tokens in item, m=tokens in query
|
||||
- Single item: < 0.1ms
|
||||
- Batch operations are linear with items
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def scorer(self):
|
||||
"""Create BM25 scorer instance."""
|
||||
from headroom.relevance.bm25 import BM25Scorer
|
||||
|
||||
return BM25Scorer()
|
||||
|
||||
def test_single_item(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark scoring a single item.
|
||||
|
||||
Target: < 0.1ms
|
||||
Tests basic scoring overhead.
|
||||
"""
|
||||
item = json_items_100[0]
|
||||
result = benchmark(scorer.score, item, query_context_uuid)
|
||||
|
||||
assert result.score >= 0.0
|
||||
assert result.score <= 1.0
|
||||
|
||||
def test_batch_100(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark scoring 100 items in batch.
|
||||
|
||||
Target: < 1ms
|
||||
Tests typical batch size for SmartCrusher.
|
||||
"""
|
||||
results = benchmark(scorer.score_batch, json_items_100, query_context_uuid)
|
||||
|
||||
assert len(results) == 100
|
||||
assert all(0.0 <= r.score <= 1.0 for r in results)
|
||||
|
||||
def test_batch_1000(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
json_items_1000,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark scoring 1000 items in batch.
|
||||
|
||||
Target: < 10ms
|
||||
Tests larger batch for stress testing.
|
||||
"""
|
||||
results = benchmark(scorer.score_batch, json_items_1000, query_context_uuid)
|
||||
|
||||
assert len(results) == 1000
|
||||
|
||||
def test_uuid_matching(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark UUID pattern matching.
|
||||
|
||||
Target: < 1ms
|
||||
Tests regex efficiency for UUID detection.
|
||||
"""
|
||||
# Query contains UUID - tests that BM25 can handle long token patterns
|
||||
results = benchmark(scorer.score_batch, json_items_100, query_context_uuid)
|
||||
|
||||
# Verify scoring completes - specific matches depend on generated data
|
||||
assert len(results) == 100
|
||||
assert all(r.score >= 0.0 for r in results)
|
||||
|
||||
def test_semantic_query(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
json_items_100,
|
||||
query_context_semantic,
|
||||
):
|
||||
"""Benchmark semantic query (BM25 limitations).
|
||||
|
||||
Target: < 1ms
|
||||
Tests keyword matching on semantic queries.
|
||||
"""
|
||||
# BM25 will only match literal terms
|
||||
results = benchmark(scorer.score_batch, json_items_100, query_context_semantic)
|
||||
|
||||
assert len(results) == 100
|
||||
|
||||
def test_empty_context(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
json_items_100,
|
||||
):
|
||||
"""Benchmark with empty query context.
|
||||
|
||||
Target: < 0.5ms
|
||||
Tests early-exit optimization.
|
||||
"""
|
||||
results = benchmark(scorer.score_batch, json_items_100, "")
|
||||
|
||||
# All scores should be 0 with no context
|
||||
assert all(r.score == 0.0 for r in results)
|
||||
|
||||
def test_long_items(
|
||||
self,
|
||||
benchmark,
|
||||
scorer,
|
||||
log_entries_1000,
|
||||
query_context_semantic,
|
||||
):
|
||||
"""Benchmark scoring longer items (log entries).
|
||||
|
||||
Target: < 15ms
|
||||
Tests performance with larger text per item.
|
||||
"""
|
||||
json_items = [json.dumps(entry) for entry in log_entries_1000]
|
||||
results = benchmark(scorer.score_batch, json_items, query_context_semantic)
|
||||
|
||||
assert len(results) == 1000
|
||||
|
||||
|
||||
class TestHybridBenchmarks:
|
||||
"""Benchmarks for Hybrid BM25+Embedding scorer.
|
||||
|
||||
HybridScorer performs:
|
||||
- BM25 scoring (always)
|
||||
- Embedding scoring (if available)
|
||||
- Adaptive alpha computation
|
||||
- Score fusion
|
||||
|
||||
Without embeddings (fallback mode):
|
||||
- Single item: < 0.2ms
|
||||
- Batch 100: < 2ms
|
||||
|
||||
With embeddings (full mode):
|
||||
- Single item: < 5ms (model inference)
|
||||
- Batch 100: < 50ms (batched inference)
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def scorer_fallback(self):
|
||||
"""Create hybrid scorer without embeddings (BM25 fallback)."""
|
||||
from headroom.relevance.bm25 import BM25Scorer
|
||||
from headroom.relevance.hybrid import HybridScorer
|
||||
|
||||
# Force BM25-only mode by not providing embedding scorer
|
||||
scorer = HybridScorer(
|
||||
alpha=0.5,
|
||||
adaptive=True,
|
||||
bm25_scorer=BM25Scorer(),
|
||||
embedding_scorer=None,
|
||||
)
|
||||
# Ensure we're in fallback mode
|
||||
scorer._embedding_available = False
|
||||
return scorer
|
||||
|
||||
@pytest.fixture
|
||||
def scorer_full(self):
|
||||
"""Create hybrid scorer with embeddings (if available)."""
|
||||
from headroom.relevance.hybrid import HybridScorer
|
||||
|
||||
scorer = HybridScorer(alpha=0.5, adaptive=True)
|
||||
return scorer
|
||||
|
||||
def test_single_item_fallback(
|
||||
self,
|
||||
benchmark,
|
||||
scorer_fallback,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark single item scoring (BM25 fallback).
|
||||
|
||||
Target: < 0.2ms
|
||||
Tests fallback mode overhead.
|
||||
"""
|
||||
item = json_items_100[0]
|
||||
result = benchmark(scorer_fallback.score, item, query_context_uuid)
|
||||
|
||||
assert "BM25 only" in result.reason
|
||||
|
||||
def test_batch_100_fallback(
|
||||
self,
|
||||
benchmark,
|
||||
scorer_fallback,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark batch scoring (BM25 fallback).
|
||||
|
||||
Target: < 2ms
|
||||
Tests fallback batch performance.
|
||||
"""
|
||||
results = benchmark(scorer_fallback.score_batch, json_items_100, query_context_uuid)
|
||||
|
||||
assert len(results) == 100
|
||||
|
||||
def test_adaptive_alpha_uuid(
|
||||
self,
|
||||
benchmark,
|
||||
scorer_fallback,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark adaptive alpha with UUID query.
|
||||
|
||||
Target: < 2ms
|
||||
Tests alpha computation overhead.
|
||||
"""
|
||||
results = benchmark(scorer_fallback.score_batch, json_items_100, query_context_uuid)
|
||||
|
||||
# UUID query should favor BM25 (but we're in fallback mode)
|
||||
assert len(results) == 100
|
||||
|
||||
def test_adaptive_alpha_semantic(
|
||||
self,
|
||||
benchmark,
|
||||
scorer_fallback,
|
||||
json_items_100,
|
||||
query_context_semantic,
|
||||
):
|
||||
"""Benchmark adaptive alpha with semantic query.
|
||||
|
||||
Target: < 2ms
|
||||
Tests alpha computation for semantic queries.
|
||||
"""
|
||||
results = benchmark(scorer_fallback.score_batch, json_items_100, query_context_semantic)
|
||||
|
||||
assert len(results) == 100
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _check_embedding_available(),
|
||||
reason="sentence-transformers not installed",
|
||||
)
|
||||
def test_single_item_full(
|
||||
self,
|
||||
benchmark,
|
||||
scorer_full,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark single item with embeddings.
|
||||
|
||||
Target: < 5ms
|
||||
Tests full hybrid mode (requires sentence-transformers).
|
||||
"""
|
||||
if not scorer_full.has_embedding_support():
|
||||
pytest.skip("Embeddings not available")
|
||||
|
||||
item = json_items_100[0]
|
||||
result = benchmark(scorer_full.score, item, query_context_uuid)
|
||||
|
||||
# Should show hybrid scoring
|
||||
assert "Hybrid" in result.reason
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _check_embedding_available(),
|
||||
reason="sentence-transformers not installed",
|
||||
)
|
||||
def test_batch_100_full(
|
||||
self,
|
||||
benchmark,
|
||||
scorer_full,
|
||||
json_items_100,
|
||||
query_context_uuid,
|
||||
):
|
||||
"""Benchmark batch scoring with embeddings.
|
||||
|
||||
Target: < 50ms
|
||||
Tests batched embedding inference.
|
||||
"""
|
||||
if not scorer_full.has_embedding_support():
|
||||
pytest.skip("Embeddings not available")
|
||||
|
||||
results = benchmark(scorer_full.score_batch, json_items_100, query_context_uuid)
|
||||
|
||||
assert len(results) == 100
|
||||
|
||||
|
||||
class TestScorerFactoryBenchmarks:
|
||||
"""Benchmarks for scorer factory and initialization."""
|
||||
|
||||
def test_create_bm25_scorer(self, benchmark):
|
||||
"""Benchmark BM25 scorer creation.
|
||||
|
||||
Target: < 0.1ms
|
||||
Tests initialization overhead.
|
||||
"""
|
||||
from headroom.relevance import create_scorer
|
||||
|
||||
scorer = benchmark(create_scorer, tier="bm25")
|
||||
|
||||
assert scorer is not None
|
||||
|
||||
def test_create_hybrid_scorer(self, benchmark):
|
||||
"""Benchmark hybrid scorer creation.
|
||||
|
||||
Target: < 1ms (without embedding model load)
|
||||
Tests initialization with fallback.
|
||||
"""
|
||||
from headroom.relevance import create_scorer
|
||||
|
||||
scorer = benchmark(create_scorer, tier="hybrid")
|
||||
|
||||
assert scorer is not None
|
||||
|
||||
|
||||
class TestRelevanceInSmartCrusher:
|
||||
"""Benchmarks for relevance scoring within SmartCrusher context.
|
||||
|
||||
Tests the realistic scenario where SmartCrusher uses relevance
|
||||
scoring to determine which items to preserve during compression.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def crusher_with_bm25(self, smart_crusher_config):
|
||||
"""SmartCrusher with BM25 relevance scorer."""
|
||||
from headroom.config import RelevanceScorerConfig
|
||||
from headroom.transforms.smart_crusher import SmartCrusher
|
||||
|
||||
return SmartCrusher(
|
||||
config=smart_crusher_config,
|
||||
relevance_config=RelevanceScorerConfig(tier="bm25"),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def crusher_with_hybrid(self, smart_crusher_config):
|
||||
"""SmartCrusher with hybrid relevance scorer."""
|
||||
from headroom.config import RelevanceScorerConfig
|
||||
from headroom.transforms.smart_crusher import SmartCrusher
|
||||
|
||||
return SmartCrusher(
|
||||
config=smart_crusher_config,
|
||||
relevance_config=RelevanceScorerConfig(tier="hybrid"),
|
||||
)
|
||||
|
||||
def test_crush_with_bm25_relevance(
|
||||
self,
|
||||
benchmark,
|
||||
crusher_with_bm25,
|
||||
mock_tokenizer,
|
||||
items_100,
|
||||
):
|
||||
"""Benchmark crushing with BM25 relevance scoring.
|
||||
|
||||
Target: < 3ms
|
||||
Tests BM25 integration overhead.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Find user 550e8400-e29b-41d4-a716-446655440000"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(items_100),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher_with_bm25.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_crush_with_hybrid_relevance(
|
||||
self,
|
||||
benchmark,
|
||||
crusher_with_hybrid,
|
||||
mock_tokenizer,
|
||||
items_100,
|
||||
):
|
||||
"""Benchmark crushing with hybrid relevance scoring.
|
||||
|
||||
Target: < 60ms (with embeddings) or < 3ms (fallback)
|
||||
Tests hybrid integration.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Show me failed requests and errors"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(items_100),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher_with_hybrid.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_crush_large_with_relevance(
|
||||
self,
|
||||
benchmark,
|
||||
crusher_with_bm25,
|
||||
mock_tokenizer,
|
||||
items_1000,
|
||||
):
|
||||
"""Benchmark crushing 1000 items with relevance.
|
||||
|
||||
Target: < 15ms
|
||||
Tests scalability of relevance scoring.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Search for Alice and find any errors"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(items_1000),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher_with_bm25.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
|
||||
def _check_embedding_available() -> bool:
|
||||
"""Check if embedding scorer is available."""
|
||||
try:
|
||||
from headroom.relevance.embedding import EmbeddingScorer
|
||||
|
||||
return EmbeddingScorer.is_available()
|
||||
except ImportError:
|
||||
return False
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Transform benchmarks for Headroom SDK.
|
||||
|
||||
This module contains performance benchmarks for Headroom transforms:
|
||||
- SmartCrusher: Statistical tool output compression
|
||||
- CacheAligner: Cache-aligned prefix optimization
|
||||
|
||||
Performance Targets:
|
||||
SmartCrusher:
|
||||
- 100 items: < 2ms
|
||||
- 1000 items: < 10ms
|
||||
- 10000 items: < 100ms
|
||||
|
||||
CacheAligner:
|
||||
- Date extraction: < 1ms
|
||||
- Hash computation: < 0.5ms
|
||||
|
||||
Run with:
|
||||
pytest benchmarks/bench_transforms.py --benchmark-only -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSmartCrusherBenchmarks:
|
||||
"""Benchmarks for SmartCrusher statistical compression.
|
||||
|
||||
SmartCrusher performs:
|
||||
- Array analysis (field statistics, pattern detection)
|
||||
- Change point detection for numeric fields
|
||||
- Relevance scoring against query context
|
||||
- Strategic sampling (first K, last K, errors, anomalies)
|
||||
|
||||
Expected performance:
|
||||
- O(n) for array analysis
|
||||
- O(n) for relevance scoring (BM25)
|
||||
- Total: < 10ms for 1000 items
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def crusher(self, smart_crusher_config):
|
||||
"""Create SmartCrusher instance."""
|
||||
from headroom.transforms.smart_crusher import SmartCrusher
|
||||
|
||||
return SmartCrusher(config=smart_crusher_config)
|
||||
|
||||
def test_compress_100_items(
|
||||
self,
|
||||
benchmark,
|
||||
crusher,
|
||||
mock_tokenizer,
|
||||
items_100,
|
||||
):
|
||||
"""Benchmark crushing 100 search results.
|
||||
|
||||
Target: < 2ms
|
||||
This is the typical size for API responses.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Search for users"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(items_100),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher.apply, messages, mock_tokenizer)
|
||||
|
||||
# Verify compression occurred
|
||||
assert result.tokens_after < result.tokens_before
|
||||
assert len(result.transforms_applied) > 0
|
||||
|
||||
def test_compress_1000_items(
|
||||
self,
|
||||
benchmark,
|
||||
crusher,
|
||||
mock_tokenizer,
|
||||
items_1000,
|
||||
):
|
||||
"""Benchmark crushing 1000 search results.
|
||||
|
||||
Target: < 10ms
|
||||
This tests larger tool outputs from extensive searches.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Search for all users"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(items_1000),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_compress_10000_items(
|
||||
self,
|
||||
benchmark,
|
||||
crusher,
|
||||
mock_tokenizer,
|
||||
items_10000,
|
||||
):
|
||||
"""Benchmark crushing 10000 search results.
|
||||
|
||||
Target: < 100ms
|
||||
Stress test for very large tool outputs.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Export all data"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(items_10000),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_analyze_log_entries(
|
||||
self,
|
||||
benchmark,
|
||||
crusher,
|
||||
mock_tokenizer,
|
||||
log_entries_1000,
|
||||
):
|
||||
"""Benchmark crushing log entries (cluster detection).
|
||||
|
||||
Target: < 15ms
|
||||
Tests cluster sampling strategy for repetitive logs.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Show recent logs"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(log_entries_1000),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_analyze_metrics_with_anomalies(
|
||||
self,
|
||||
benchmark,
|
||||
crusher,
|
||||
mock_tokenizer,
|
||||
database_rows_1000,
|
||||
):
|
||||
"""Benchmark crushing metrics data (anomaly detection).
|
||||
|
||||
Target: < 15ms
|
||||
Tests change point detection and anomaly preservation.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Get CPU metrics"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": json.dumps(database_rows_1000),
|
||||
},
|
||||
]
|
||||
|
||||
result = benchmark(crusher.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_multiple_tool_outputs(
|
||||
self,
|
||||
benchmark,
|
||||
crusher,
|
||||
mock_tokenizer,
|
||||
items_100,
|
||||
log_entries_100,
|
||||
):
|
||||
"""Benchmark crushing multiple tool outputs in one pass.
|
||||
|
||||
Target: < 5ms
|
||||
Tests realistic scenario with multiple tool calls.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Search users and get logs"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "search", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {"name": "logs", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": json.dumps(items_100)},
|
||||
{"role": "tool", "tool_call_id": "call_2", "content": json.dumps(log_entries_100)},
|
||||
]
|
||||
|
||||
result = benchmark(crusher.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
|
||||
class TestCacheAlignerBenchmarks:
|
||||
"""Benchmarks for CacheAligner prefix optimization.
|
||||
|
||||
CacheAligner performs:
|
||||
- Date pattern detection and extraction
|
||||
- Whitespace normalization
|
||||
- Stable prefix hash computation
|
||||
|
||||
Expected performance:
|
||||
- Date extraction: < 1ms (regex matching)
|
||||
- Hash computation: < 0.5ms (MD5)
|
||||
- Total: < 2ms for typical system prompts
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def aligner(self, cache_aligner_config):
|
||||
"""Create CacheAligner instance."""
|
||||
from headroom.transforms.cache_aligner import CacheAligner
|
||||
|
||||
return CacheAligner(config=cache_aligner_config)
|
||||
|
||||
def test_date_extraction(
|
||||
self,
|
||||
benchmark,
|
||||
aligner,
|
||||
mock_tokenizer,
|
||||
messages_with_system_date,
|
||||
):
|
||||
"""Benchmark date extraction from system prompt.
|
||||
|
||||
Target: < 1ms
|
||||
Tests regex-based date pattern matching.
|
||||
"""
|
||||
result = benchmark(aligner.apply, messages_with_system_date, mock_tokenizer)
|
||||
|
||||
# Verify date was extracted
|
||||
assert "cache_align" in str(result.transforms_applied)
|
||||
|
||||
def test_hash_computation(
|
||||
self,
|
||||
benchmark,
|
||||
aligner,
|
||||
mock_tokenizer,
|
||||
system_prompt_long,
|
||||
):
|
||||
"""Benchmark stable prefix hash computation.
|
||||
|
||||
Target: < 0.5ms
|
||||
Tests hash stability for cache hit prediction.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt_long},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
result = benchmark(aligner.apply, messages, mock_tokenizer)
|
||||
|
||||
# Verify hash was computed
|
||||
assert result.cache_metrics is not None
|
||||
assert result.cache_metrics.stable_prefix_hash
|
||||
|
||||
def test_whitespace_normalization(
|
||||
self,
|
||||
benchmark,
|
||||
aligner,
|
||||
mock_tokenizer,
|
||||
):
|
||||
"""Benchmark whitespace normalization.
|
||||
|
||||
Target: < 0.5ms
|
||||
Tests string processing for consistent formatting.
|
||||
"""
|
||||
messy_content = """You are a helpful assistant.
|
||||
|
||||
Current date: 2025-01-06
|
||||
|
||||
|
||||
This has excessive whitespace.
|
||||
|
||||
|
||||
And multiple blank lines."""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": messy_content},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
|
||||
result = benchmark(aligner.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.messages[0]["content"] != messy_content # Was normalized
|
||||
|
||||
def test_long_system_prompt(
|
||||
self,
|
||||
benchmark,
|
||||
aligner,
|
||||
mock_tokenizer,
|
||||
system_prompt_long,
|
||||
):
|
||||
"""Benchmark processing long system prompts.
|
||||
|
||||
Target: < 2ms
|
||||
Tests performance with larger instruction sets.
|
||||
"""
|
||||
# Add date to trigger alignment
|
||||
content_with_date = system_prompt_long + "\n\nCurrent date: 2025-01-06"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": content_with_date},
|
||||
{"role": "user", "content": "Help me with code"},
|
||||
]
|
||||
|
||||
result = benchmark(aligner.apply, messages, mock_tokenizer)
|
||||
|
||||
assert result.cache_metrics is not None
|
||||
|
||||
def test_multiple_system_messages(
|
||||
self,
|
||||
benchmark,
|
||||
aligner,
|
||||
mock_tokenizer,
|
||||
):
|
||||
"""Benchmark with multiple system messages.
|
||||
|
||||
Target: < 3ms
|
||||
Tests edge case of multiple system prompts.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant.\n\nCurrent date: 2025-01-06",
|
||||
},
|
||||
{"role": "system", "content": "Additional context: Technical support mode."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
benchmark(aligner.apply, messages, mock_tokenizer)
|
||||
|
||||
|
||||
# RollingWindow benchmarks were retired in PR-B1 along with the
|
||||
# RollingWindow transform itself. Live-zone-only compression
|
||||
# (PR-B2..B7) does not drop messages, so message-count-based
|
||||
# benchmarks no longer have a baseline to measure. Phase B's own
|
||||
# performance suite lives alongside the live-zone dispatcher.
|
||||
|
||||
|
||||
class TestTransformPipelineBenchmarks:
|
||||
"""Benchmarks for full transform pipeline.
|
||||
|
||||
Tests the complete flow:
|
||||
CacheAligner -> SmartCrusher
|
||||
|
||||
Expected performance:
|
||||
- Simple conversation: < 5ms
|
||||
- Agentic with tools: < 30ms
|
||||
- Large RAG context: < 50ms
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider(self, mock_token_counter):
|
||||
"""Create mock provider for pipeline."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
provider = Mock()
|
||||
provider.get_token_counter.return_value = mock_token_counter
|
||||
return provider
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(self, smart_crusher_config, cache_aligner_config, mock_provider):
|
||||
"""Create transform pipeline.
|
||||
|
||||
PR-B1 retired RollingWindow; the live-zone-only architecture
|
||||
runs CacheAligner → SmartCrusher (followed by ContentRouter
|
||||
in production, omitted here to keep the fixture pure-stage).
|
||||
"""
|
||||
from headroom.transforms.cache_aligner import CacheAligner
|
||||
from headroom.transforms.pipeline import TransformPipeline
|
||||
from headroom.transforms.smart_crusher import SmartCrusher
|
||||
|
||||
return TransformPipeline(
|
||||
transforms=[
|
||||
CacheAligner(cache_aligner_config),
|
||||
SmartCrusher(smart_crusher_config),
|
||||
],
|
||||
provider=mock_provider,
|
||||
)
|
||||
|
||||
def test_pipeline_simple(
|
||||
self,
|
||||
benchmark,
|
||||
pipeline,
|
||||
messages_with_system_date,
|
||||
):
|
||||
"""Benchmark pipeline on simple conversation.
|
||||
|
||||
Target: < 5ms
|
||||
Tests minimal overhead scenario.
|
||||
"""
|
||||
benchmark(
|
||||
pipeline.apply,
|
||||
messages_with_system_date,
|
||||
"benchmark-model",
|
||||
model_limit=100000,
|
||||
)
|
||||
|
||||
def test_pipeline_agentic(
|
||||
self,
|
||||
benchmark,
|
||||
pipeline,
|
||||
conversation_50_turns,
|
||||
):
|
||||
"""Benchmark pipeline on agentic conversation.
|
||||
|
||||
Target: < 30ms
|
||||
Tests realistic agentic workload.
|
||||
"""
|
||||
result = benchmark(
|
||||
pipeline.apply,
|
||||
conversation_50_turns,
|
||||
"benchmark-model",
|
||||
model_limit=50000,
|
||||
)
|
||||
|
||||
assert result.tokens_after < result.tokens_before
|
||||
|
||||
def test_pipeline_rag(
|
||||
self,
|
||||
benchmark,
|
||||
pipeline,
|
||||
rag_conversation_20k,
|
||||
):
|
||||
"""Benchmark pipeline on RAG conversation.
|
||||
|
||||
Target: < 50ms
|
||||
Tests large context handling.
|
||||
|
||||
Note: CacheAligner may add small markers (e.g., "[Dynamic Context]"),
|
||||
so we allow up to 1% token increase.
|
||||
"""
|
||||
result = benchmark(
|
||||
pipeline.apply,
|
||||
rag_conversation_20k,
|
||||
"benchmark-model",
|
||||
model_limit=30000,
|
||||
)
|
||||
|
||||
# Allow for small overhead from cache alignment markers
|
||||
assert result.tokens_after <= result.tokens_before * 1.01
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace and report concrete cache-busting turns from local Claude session replays."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if __package__ in {None, ""}:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
DEFAULT_OUTPUT_DIR = Path("benchmark_results") / "cache_bust_trace"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BustEvent:
|
||||
branch: str
|
||||
mode: str
|
||||
session_id: str
|
||||
project: str
|
||||
request_id: str
|
||||
timestamp: str
|
||||
first_diff_index: int | None
|
||||
prev_len: int
|
||||
curr_len: int
|
||||
prev_msg: dict[str, Any] | None
|
||||
curr_msg: dict[str, Any] | None
|
||||
prev_tail: list[dict[str, Any]]
|
||||
curr_tail: list[dict[str, Any]]
|
||||
retroactive_rewrite: bool
|
||||
|
||||
|
||||
def _run_git(args: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def _ref_slug(ref: str) -> str:
|
||||
return "".join(ch if ch.isalnum() else "-" for ch in ref).strip("-").lower() or "ref"
|
||||
|
||||
|
||||
def _first_diff_index(prev: list[dict[str, Any]], curr: list[dict[str, Any]]) -> int | None:
|
||||
for i, (a, b) in enumerate(zip(prev, curr)):
|
||||
if a != b:
|
||||
return i
|
||||
if len(prev) != len(curr):
|
||||
return min(len(prev), len(curr))
|
||||
return None
|
||||
|
||||
|
||||
def _trace_branch(
|
||||
repo_root: Path,
|
||||
ref: str,
|
||||
label: str,
|
||||
*,
|
||||
recent_turns_per_session: int,
|
||||
max_events_per_mode: int = 10,
|
||||
) -> list[BustEvent]:
|
||||
worktree_root = Path(tempfile.mkdtemp(prefix="headroom-bust-trace-"))
|
||||
worktree_dir = worktree_root / _ref_slug(label)
|
||||
_run_git(["worktree", "add", "--detach", str(worktree_dir), ref], repo_root)
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(worktree_dir)
|
||||
code = """
|
||||
import copy, json
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
module_path = Path(os.environ['BUST_TRACE_SCRIPT'])
|
||||
spec = importlib.util.spec_from_file_location('branch_benchmark', module_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
PROXY_MODE_CACHE = mod.PROXY_MODE_CACHE
|
||||
PROXY_MODE_TOKEN = mod.PROXY_MODE_TOKEN
|
||||
PrefixCacheTracker = mod.PrefixCacheTracker
|
||||
_apply_mode_to_messages = mod._apply_mode_to_messages
|
||||
_cache_gap_within_ttl = mod._cache_gap_within_ttl
|
||||
_rewrite_scope = mod._rewrite_scope
|
||||
get_tokenizer = mod.get_tokenizer
|
||||
load_session_replay = mod.load_session_replay
|
||||
select_session_files = mod.select_session_files
|
||||
trim_replay_to_recent_turns = mod.trim_replay_to_recent_turns
|
||||
_make_proxy = mod._make_proxy
|
||||
from headroom.cache.compression_cache import CompressionCache
|
||||
|
||||
ROOT = Path.home() / '.claude' / 'projects'
|
||||
TTL = timedelta(minutes=5)
|
||||
recent_turns_per_session = int(__import__('os').environ['BUST_TRACE_RECENT'])
|
||||
max_events_per_mode = int(__import__('os').environ['BUST_TRACE_MAX'])
|
||||
|
||||
def first_diff_index(prev, curr):
|
||||
for i, (a, b) in enumerate(zip(prev, curr)):
|
||||
if a != b:
|
||||
return i
|
||||
if len(prev) != len(curr):
|
||||
return min(len(prev), len(curr))
|
||||
return None
|
||||
|
||||
def trace_mode(mode):
|
||||
proxy = _make_proxy(mode)
|
||||
session_files = select_session_files(ROOT)
|
||||
events = []
|
||||
for session_file in session_files:
|
||||
replay = load_session_replay(session_file)
|
||||
if replay is None:
|
||||
continue
|
||||
replay = trim_replay_to_recent_turns(replay, recent_turns_per_session)
|
||||
prefix_tracker = PrefixCacheTracker('anthropic')
|
||||
comp_cache = CompressionCache() if mode == PROXY_MODE_TOKEN else None
|
||||
conversation = []
|
||||
conversation_token_total = 0
|
||||
previous_forwarded = []
|
||||
previous_original_context = None
|
||||
previous_forwarded_context = None
|
||||
previous_timestamp = None
|
||||
pending = None
|
||||
for turn in replay.turns:
|
||||
tokenizer = get_tokenizer(turn.model)
|
||||
turn_input_token_total = sum(tokenizer.count_message(msg) for msg in turn.input_messages)
|
||||
prior_context_message_count = len(conversation)
|
||||
conversation.extend(turn.input_messages)
|
||||
raw_input_tokens = conversation_token_total + turn_input_token_total
|
||||
forwarded = _apply_mode_to_messages(
|
||||
proxy, mode, conversation,
|
||||
model=turn.model, prefix_tracker=prefix_tracker, comp_cache=comp_cache,
|
||||
previous_original_messages=previous_original_context,
|
||||
previous_forwarded_messages=previous_forwarded_context,
|
||||
)
|
||||
if pending is not None:
|
||||
eligible = _cache_gap_within_ttl(pending.turn.timestamp, previous_timestamp, ttl=TTL)
|
||||
if eligible and previous_forwarded:
|
||||
prefix_preserved = (
|
||||
len(pending.forwarded) >= len(previous_forwarded)
|
||||
and pending.forwarded[: len(previous_forwarded)] == previous_forwarded
|
||||
)
|
||||
if not prefix_preserved:
|
||||
idx = first_diff_index(previous_forwarded, pending.forwarded)
|
||||
_, retro = _rewrite_scope(
|
||||
pending.request_messages,
|
||||
pending.forwarded,
|
||||
stable_prefix_message_count=max(len(previous_forwarded) - 1, 0),
|
||||
)
|
||||
events.append({
|
||||
'mode': mode,
|
||||
'session_id': replay.session_id,
|
||||
'project': replay.decoded_project_path,
|
||||
'request_id': pending.turn.request_id,
|
||||
'timestamp': pending.turn.timestamp.isoformat(),
|
||||
'first_diff_index': idx,
|
||||
'prev_len': len(previous_forwarded),
|
||||
'curr_len': len(pending.forwarded),
|
||||
'prev_msg': previous_forwarded[idx] if idx is not None and idx < len(previous_forwarded) else None,
|
||||
'curr_msg': pending.forwarded[idx] if idx is not None and idx < len(pending.forwarded) else None,
|
||||
'prev_tail': previous_forwarded_context[-4:] if previous_forwarded_context else [],
|
||||
'curr_tail': pending.request_messages[-4:],
|
||||
'retroactive_rewrite': retro,
|
||||
})
|
||||
if len(events) >= max_events_per_mode:
|
||||
return events
|
||||
previous_forwarded = copy.deepcopy(pending.forwarded)
|
||||
previous_timestamp = pending.turn.timestamp
|
||||
try:
|
||||
prefix_tracker.update_from_response(
|
||||
cache_read_tokens=0,
|
||||
cache_write_tokens=0,
|
||||
messages=forwarded,
|
||||
message_token_counts=[tokenizer.count_message(msg) for msg in forwarded],
|
||||
original_messages=conversation,
|
||||
)
|
||||
except TypeError:
|
||||
prefix_tracker.update_from_response(
|
||||
cache_read_tokens=0,
|
||||
cache_write_tokens=0,
|
||||
messages=forwarded,
|
||||
message_token_counts=[tokenizer.count_message(msg) for msg in forwarded],
|
||||
)
|
||||
class Pending: pass
|
||||
pending = Pending()
|
||||
pending.turn = turn
|
||||
pending.request_messages = copy.deepcopy(conversation)
|
||||
pending.forwarded = forwarded
|
||||
conversation.append(turn.assistant_message)
|
||||
conversation_token_total = raw_input_tokens + tokenizer.count_message(turn.assistant_message)
|
||||
previous_original_context = copy.deepcopy(conversation)
|
||||
previous_forwarded_context = copy.deepcopy(forwarded) + [copy.deepcopy(turn.assistant_message)]
|
||||
return events
|
||||
|
||||
print(json.dumps({
|
||||
'token': trace_mode(PROXY_MODE_TOKEN),
|
||||
'cache': trace_mode(PROXY_MODE_CACHE),
|
||||
}, indent=2))
|
||||
"""
|
||||
env["BUST_TRACE_RECENT"] = str(recent_turns_per_session)
|
||||
env["BUST_TRACE_MAX"] = str(max_events_per_mode)
|
||||
script_path = worktree_dir / "benchmarks" / "claude_session_mode_benchmark.py"
|
||||
if not script_path.exists():
|
||||
script_path = repo_root / "benchmarks" / "claude_session_mode_benchmark.py"
|
||||
env["BUST_TRACE_SCRIPT"] = str(script_path)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=worktree_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
payload = json.loads(completed.stdout)
|
||||
events: list[BustEvent] = []
|
||||
for mode in ("token", "cache"):
|
||||
for item in payload[mode]:
|
||||
events.append(BustEvent(branch=label, **item))
|
||||
return events
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"trace failed for {label} ({ref})\nSTDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}"
|
||||
) from exc
|
||||
finally:
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(worktree_dir)],
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _render_markdown(events: list[BustEvent], recent_turns_per_session: int) -> str:
|
||||
lines = [
|
||||
"# Cache Bust Trace Report",
|
||||
"",
|
||||
f"- Sampling: most recent {recent_turns_per_session} turns per session",
|
||||
"",
|
||||
]
|
||||
for branch in ("main", "pr"):
|
||||
lines.append(f"## {branch}")
|
||||
lines.append("")
|
||||
branch_events = [e for e in events if e.branch == branch]
|
||||
for mode in ("token", "cache"):
|
||||
lines.append(f"### {mode}")
|
||||
mode_events = [e for e in branch_events if e.mode == mode]
|
||||
if not mode_events:
|
||||
lines.append("")
|
||||
lines.append("- No bust events captured.")
|
||||
lines.append("")
|
||||
continue
|
||||
for event in mode_events:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"- `{event.project}` `{event.session_id}` `{event.request_id}` "
|
||||
f"{event.timestamp} diff_index={event.first_diff_index} "
|
||||
f"retroactive={event.retroactive_rewrite}"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_html(events: list[BustEvent], recent_turns_per_session: int) -> str:
|
||||
sections = []
|
||||
for branch in ("main", "pr"):
|
||||
rows = []
|
||||
branch_events = [e for e in events if e.branch == branch]
|
||||
for mode in ("token", "cache"):
|
||||
mode_events = [e for e in branch_events if e.mode == mode]
|
||||
if not mode_events:
|
||||
rows.append(
|
||||
f"<tr><td>{mode}</td><td colspan='6'>No bust events captured.</td></tr>"
|
||||
)
|
||||
continue
|
||||
for event in mode_events:
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>{mode}</td>"
|
||||
f"<td>{event.project}</td>"
|
||||
f"<td>{event.session_id}</td>"
|
||||
f"<td>{event.request_id}</td>"
|
||||
f"<td>{event.timestamp}</td>"
|
||||
f"<td>{event.first_diff_index}</td>"
|
||||
f"<td>{event.retroactive_rewrite}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
sections.append(
|
||||
f"<section><h2>{branch}</h2><table><thead><tr>"
|
||||
"<th>Mode</th><th>Project</th><th>Session</th><th>Request</th>"
|
||||
"<th>Timestamp</th><th>First Diff</th><th>Retroactive</th>"
|
||||
f"</tr></thead><tbody>{''.join(rows)}</tbody></table></section>"
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cache Bust Trace Report</title>
|
||||
<style>
|
||||
body {{ font-family: 'Segoe UI', system-ui, sans-serif; margin: 0; background: #f8fafc; color: #0f172a; }}
|
||||
.shell {{ max-width: 1280px; margin: 0 auto; padding: 32px 16px 48px; }}
|
||||
h1, h2 {{ letter-spacing: -0.02em; }}
|
||||
section {{ background: white; border: 1px solid #e2e8f0; border-radius: 16px; padding: 20px; margin-top: 16px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 10px 12px; border-bottom: 1px solid #e2e8f0; text-align: left; white-space: nowrap; }}
|
||||
th {{ background: #f1f5f9; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<h1>Cache Bust Trace Report</h1>
|
||||
<p>Most recent {recent_turns_per_session} turns per session.</p>
|
||||
{"".join(sections)}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
output_dir = DEFAULT_OUTPUT_DIR
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
recent_turns_per_session = 200
|
||||
events = _trace_branch(
|
||||
repo_root, "upstream/main", "main", recent_turns_per_session=recent_turns_per_session
|
||||
)
|
||||
events.extend(
|
||||
_trace_branch(repo_root, "HEAD", "pr", recent_turns_per_session=recent_turns_per_session)
|
||||
)
|
||||
|
||||
md_path = output_dir / "cache_bust_trace.md"
|
||||
json_path = output_dir / "cache_bust_trace.json"
|
||||
html_path = output_dir / "cache_bust_trace.html"
|
||||
md_path.write_text(_render_markdown(events, recent_turns_per_session), encoding="utf-8")
|
||||
json_path.write_text(
|
||||
json.dumps([asdict(event) for event in events], indent=2), encoding="utf-8"
|
||||
)
|
||||
html_path.write_text(_render_html(events, recent_turns_per_session), encoding="utf-8")
|
||||
print(md_path)
|
||||
print(json_path)
|
||||
print(html_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,715 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a reproducible local cache-validation report bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if __package__ in {None, ""}:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import benchmarks.claude_session_mode_benchmark as real_bench
|
||||
import benchmarks.synthetic_long_cache_suite_report as long_suite
|
||||
import benchmarks.synthetic_token_cache_bust_report as token_bust
|
||||
from benchmarks.claude_session_mode_benchmark import (
|
||||
PROXY_MODE_CACHE,
|
||||
PROXY_MODE_TOKEN,
|
||||
_apply_mode_to_messages,
|
||||
_cache_gap_within_ttl,
|
||||
_rewrite_scope,
|
||||
build_dataset_and_observed_from_files,
|
||||
determine_winners,
|
||||
format_currency,
|
||||
get_tokenizer,
|
||||
load_session_replay,
|
||||
resolve_checkpoint_dir,
|
||||
select_session_files,
|
||||
simulate_session_files,
|
||||
trim_replay_to_recent_turns,
|
||||
write_report,
|
||||
)
|
||||
from headroom.cache.compression_cache import CompressionCache
|
||||
from headroom.cache.prefix_tracker import PrefixCacheTracker
|
||||
|
||||
DEFAULT_OUTPUT_DIR = Path("benchmark_results") / "cache_validation_bundle"
|
||||
|
||||
|
||||
def _excerpt_content(content: Any, *, max_chars: int) -> str:
|
||||
if isinstance(content, str):
|
||||
text = content.replace("\n", " ")
|
||||
return text[:max_chars] + ("..." if len(text) > max_chars else "")
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content[:4]:
|
||||
if isinstance(block, dict):
|
||||
btype = str(block.get("type", "unknown"))
|
||||
bcontent = block.get("content", "")
|
||||
if isinstance(bcontent, str):
|
||||
bcontent = bcontent.replace("\n", " ")
|
||||
bcontent = bcontent[:max_chars] + ("..." if len(bcontent) > max_chars else "")
|
||||
parts.append(f"[{btype}] {bcontent}")
|
||||
else:
|
||||
parts.append(str(block)[:max_chars])
|
||||
return " | ".join(parts)
|
||||
return str(content)[:max_chars]
|
||||
|
||||
|
||||
def _message_preview(msg: dict[str, Any], *, max_chars: int) -> dict[str, str]:
|
||||
return {
|
||||
"role": str(msg.get("role")),
|
||||
"content_excerpt": _excerpt_content(msg.get("content"), max_chars=max_chars),
|
||||
}
|
||||
|
||||
|
||||
def _stable_hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _redact_text(value: str, *, prefix: str) -> str:
|
||||
return f"{prefix}-{_stable_hash(value)}"
|
||||
|
||||
|
||||
def _redact_path(value: str) -> str:
|
||||
path = Path(value)
|
||||
suffix = path.suffix
|
||||
return f"path-{_stable_hash(value)}{suffix}"
|
||||
|
||||
|
||||
def _git_output(args: list[str], cwd: Path) -> str | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _runtime_metadata(repo_root: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"git_sha": _git_output(["rev-parse", "HEAD"], repo_root),
|
||||
"git_dirty": bool(_git_output(["status", "--porcelain"], repo_root)),
|
||||
"python_version": sys.version,
|
||||
"platform": platform.platform(),
|
||||
"implementation": platform.python_implementation(),
|
||||
}
|
||||
|
||||
|
||||
def _corpus_fingerprint(
|
||||
*,
|
||||
root: Path,
|
||||
session_files: list[Path],
|
||||
max_sessions: int | None,
|
||||
recent_turns_per_session: int | None,
|
||||
cache_ttl_minutes: int,
|
||||
) -> dict[str, Any]:
|
||||
normalized_files = [str(p.resolve()) for p in session_files]
|
||||
payload = {
|
||||
"root": str(root.resolve()),
|
||||
"session_files": normalized_files,
|
||||
"max_sessions": max_sessions,
|
||||
"recent_turns_per_session": recent_turns_per_session,
|
||||
"cache_ttl_minutes": cache_ttl_minutes,
|
||||
}
|
||||
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"root": str(root.resolve()),
|
||||
"session_file_count": len(session_files),
|
||||
"session_files_sha256": digest,
|
||||
"max_sessions": max_sessions,
|
||||
"recent_turns_per_session": recent_turns_per_session,
|
||||
"cache_ttl_minutes": cache_ttl_minutes,
|
||||
}
|
||||
|
||||
|
||||
def _collect_real_processed_events(
|
||||
*,
|
||||
root: Path,
|
||||
recent_turns_per_session: int | None,
|
||||
max_events_per_mode: int,
|
||||
ttl_minutes: int,
|
||||
max_chars: int,
|
||||
include_content: bool,
|
||||
) -> dict[str, Any]:
|
||||
ttl = timedelta(minutes=ttl_minutes)
|
||||
events: list[dict[str, Any]] = []
|
||||
session_files = select_session_files(root)
|
||||
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
||||
proxy = real_bench._make_proxy(mode)
|
||||
collected = 0
|
||||
for session_file in session_files:
|
||||
replay = load_session_replay(session_file)
|
||||
if replay is None:
|
||||
continue
|
||||
replay = trim_replay_to_recent_turns(replay, recent_turns_per_session)
|
||||
prefix_tracker = PrefixCacheTracker("anthropic")
|
||||
comp_cache = CompressionCache() if mode == PROXY_MODE_TOKEN else None
|
||||
conversation: list[dict[str, Any]] = []
|
||||
previous_original_context: list[dict[str, Any]] | None = None
|
||||
previous_forwarded_context: list[dict[str, Any]] | None = None
|
||||
previous_forwarded: list[dict[str, Any]] = []
|
||||
previous_timestamp = None
|
||||
pending = None
|
||||
for turn in replay.turns:
|
||||
tokenizer = get_tokenizer(turn.model)
|
||||
prior_context_message_count = len(conversation)
|
||||
conversation.extend(turn.input_messages)
|
||||
forwarded = _apply_mode_to_messages(
|
||||
proxy,
|
||||
mode,
|
||||
conversation,
|
||||
model=turn.model,
|
||||
prefix_tracker=prefix_tracker,
|
||||
comp_cache=comp_cache,
|
||||
previous_original_messages=previous_original_context,
|
||||
previous_forwarded_messages=previous_forwarded_context,
|
||||
)
|
||||
rewrite, retro = _rewrite_scope(
|
||||
conversation,
|
||||
forwarded,
|
||||
stable_prefix_message_count=prior_context_message_count,
|
||||
)
|
||||
if rewrite:
|
||||
prior_forwarded = (
|
||||
pending.forwarded if pending is not None else previous_forwarded
|
||||
)
|
||||
prior_ts = pending.turn.timestamp if pending is not None else previous_timestamp
|
||||
eligible = bool(
|
||||
prior_ts is not None
|
||||
and _cache_gap_within_ttl(turn.timestamp, prior_ts, ttl=ttl)
|
||||
and prior_forwarded
|
||||
)
|
||||
prefix_preserved = None
|
||||
first_diff_index = None
|
||||
if eligible:
|
||||
prefix_preserved = (
|
||||
len(forwarded) >= len(prior_forwarded)
|
||||
and forwarded[: len(prior_forwarded)] == prior_forwarded
|
||||
)
|
||||
if not prefix_preserved:
|
||||
for idx, (a, b) in enumerate(zip(prior_forwarded, forwarded)):
|
||||
if a != b:
|
||||
first_diff_index = idx
|
||||
break
|
||||
if first_diff_index is None:
|
||||
first_diff_index = min(len(prior_forwarded), len(forwarded))
|
||||
events.append(
|
||||
{
|
||||
"mode": mode,
|
||||
"session_id": replay.session_id
|
||||
if include_content
|
||||
else _redact_text(replay.session_id, prefix="session"),
|
||||
"project": replay.decoded_project_path
|
||||
if include_content
|
||||
else _redact_path(replay.decoded_project_path),
|
||||
"request_id": turn.request_id
|
||||
if include_content
|
||||
else _redact_text(turn.request_id, prefix="request"),
|
||||
"timestamp": turn.timestamp.isoformat(),
|
||||
"cache_eligible": eligible,
|
||||
"prefix_preserved": prefix_preserved,
|
||||
"retroactive_rewrite": retro,
|
||||
"first_diff_index": first_diff_index,
|
||||
"original_tail": [
|
||||
_message_preview(m, max_chars=max_chars)
|
||||
if include_content
|
||||
else {
|
||||
"role": str(m.get("role")),
|
||||
"content_excerpt": "[redacted]",
|
||||
}
|
||||
for m in conversation[max(0, len(conversation) - 4) :]
|
||||
],
|
||||
"forwarded_tail": [
|
||||
_message_preview(m, max_chars=max_chars)
|
||||
if include_content
|
||||
else {
|
||||
"role": str(m.get("role")),
|
||||
"content_excerpt": "[redacted]",
|
||||
}
|
||||
for m in forwarded[max(0, len(forwarded) - 4) :]
|
||||
],
|
||||
}
|
||||
)
|
||||
collected += 1
|
||||
if collected >= max_events_per_mode:
|
||||
break
|
||||
if pending is not None:
|
||||
previous_forwarded = copy.deepcopy(pending.forwarded)
|
||||
previous_timestamp = pending.turn.timestamp
|
||||
real_bench._update_prefix_tracker(
|
||||
prefix_tracker,
|
||||
cache_read_tokens=0,
|
||||
cache_write_tokens=0,
|
||||
messages=forwarded,
|
||||
message_token_counts=[tokenizer.count_message(msg) for msg in forwarded],
|
||||
original_messages=conversation,
|
||||
)
|
||||
|
||||
class Pending:
|
||||
pass
|
||||
|
||||
pending = Pending()
|
||||
pending.turn = turn
|
||||
pending.forwarded = forwarded
|
||||
conversation.append(turn.assistant_message)
|
||||
previous_original_context = copy.deepcopy(conversation)
|
||||
previous_forwarded_context = copy.deepcopy(forwarded) + [
|
||||
copy.deepcopy(turn.assistant_message)
|
||||
]
|
||||
if collected >= max_events_per_mode:
|
||||
break
|
||||
return {"events": events}
|
||||
|
||||
|
||||
def _write_processed_event_reports(
|
||||
output_dir: Path, payload: dict[str, Any]
|
||||
) -> tuple[Path, Path, Path]:
|
||||
out_dir = output_dir / "real_processed"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = out_dir / "real_processed_rewrite_report.json"
|
||||
md_path = out_dir / "real_processed_rewrite_report.md"
|
||||
html_path = out_dir / "real_processed_rewrite_report.html"
|
||||
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
md = [
|
||||
"# Real Processed Rewrite Report",
|
||||
"",
|
||||
"Local-only report from real Claude transcript replays. Do not commit.",
|
||||
"",
|
||||
]
|
||||
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
||||
mode_events = [e for e in payload["events"] if e["mode"] == mode]
|
||||
md.extend([f"## `{mode}`", ""])
|
||||
if not mode_events:
|
||||
md.extend(["No rewrite events captured.", ""])
|
||||
continue
|
||||
for i, e in enumerate(mode_events, start=1):
|
||||
md.extend(
|
||||
[
|
||||
f"### Event {i}",
|
||||
"",
|
||||
f"- session: `{e['session_id']}`",
|
||||
f"- request: `{e['request_id']}`",
|
||||
f"- cache eligible: `{e['cache_eligible']}`",
|
||||
f"- prefix preserved: `{e['prefix_preserved']}`",
|
||||
f"- retroactive rewrite: `{e['retroactive_rewrite']}`",
|
||||
f"- first diff index: `{e['first_diff_index']}`",
|
||||
"",
|
||||
"**Original Tail**",
|
||||
"",
|
||||
]
|
||||
)
|
||||
for msg in e["original_tail"]:
|
||||
md.append(f"- `{msg['role']}`: {msg['content_excerpt']}")
|
||||
md.extend(["", "**Forwarded Tail**", ""])
|
||||
for msg in e["forwarded_tail"]:
|
||||
md.append(f"- `{msg['role']}`: {msg['content_excerpt']}")
|
||||
md.extend(["", ""])
|
||||
md_path.write_text("\n".join(md), encoding="utf-8")
|
||||
|
||||
sections = []
|
||||
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
||||
mode_events = [e for e in payload["events"] if e["mode"] == mode]
|
||||
cards = []
|
||||
for i, e in enumerate(mode_events, start=1):
|
||||
orig = "".join(
|
||||
f"<li><code>{html.escape(str(m['role']))}</code>: "
|
||||
f"{html.escape(str(m['content_excerpt']))}</li>"
|
||||
for m in e["original_tail"]
|
||||
)
|
||||
fwd = "".join(
|
||||
f"<li><code>{html.escape(str(m['role']))}</code>: "
|
||||
f"{html.escape(str(m['content_excerpt']))}</li>"
|
||||
for m in e["forwarded_tail"]
|
||||
)
|
||||
cards.append(
|
||||
"<div class='event'>"
|
||||
f"<h3>Event {i}</h3>"
|
||||
f"<p><strong>session</strong>: <code>{html.escape(e['session_id'])}</code><br>"
|
||||
f"<strong>request</strong>: <code>{html.escape(e['request_id'])}</code><br>"
|
||||
f"<strong>cache eligible</strong>: <code>{e['cache_eligible']}</code><br>"
|
||||
f"<strong>prefix preserved</strong>: <code>{e['prefix_preserved']}</code><br>"
|
||||
f"<strong>retroactive rewrite</strong>: <code>{e['retroactive_rewrite']}</code><br>"
|
||||
f"<strong>first diff index</strong>: <code>{e['first_diff_index']}</code></p>"
|
||||
f"<div class='cols'><div><h4>Original Tail</h4><ul>{orig}</ul></div>"
|
||||
f"<div><h4>Forwarded Tail</h4><ul>{fwd}</ul></div></div>"
|
||||
"</div>"
|
||||
)
|
||||
sections.append(
|
||||
f"<section class='card'><h2>{html.escape(mode)}</h2>"
|
||||
+ ("".join(cards) if cards else "<p>No rewrite events captured.</p>")
|
||||
+ "</section>"
|
||||
)
|
||||
|
||||
html_doc = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>Real Processed Rewrite Report</title>"
|
||||
"<style>"
|
||||
"body{font-family:ui-sans-serif,system-ui,sans-serif;max-width:1200px;margin:40px auto;padding:0 20px;line-height:1.55;color:#111827;background:#f8fafc}"
|
||||
".card,.event{background:white;border:1px solid #cbd5e1;border-radius:16px;padding:20px;margin:18px 0;box-shadow:0 8px 24px rgba(15,23,42,.06)}"
|
||||
".cols{display:grid;grid-template-columns:1fr 1fr;gap:20px} code{background:#e5e7eb;padding:1px 4px;border-radius:4px} ul{padding-left:20px}"
|
||||
"</style></head><body>"
|
||||
"<h1>Real Processed Rewrite Report</h1>"
|
||||
"<div class='card'><p>Local-only report from real Claude transcript replays. Do not commit.</p></div>"
|
||||
+ "".join(sections)
|
||||
+ "</body></html>"
|
||||
)
|
||||
html_path.write_text(html_doc, encoding="utf-8")
|
||||
return md_path, json_path, html_path
|
||||
|
||||
|
||||
def _write_index(
|
||||
output_dir: Path,
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
dataset: dict[str, Any],
|
||||
observed: dict[str, Any],
|
||||
summaries: dict[str, Any],
|
||||
winners: dict[str, str],
|
||||
metadata: dict[str, Any],
|
||||
corpus: dict[str, Any],
|
||||
processed_paths: tuple[Path, Path, Path],
|
||||
token_bust_paths: tuple[Path, Path, Path],
|
||||
long_suite_paths: tuple[Path, Path, Path],
|
||||
) -> tuple[Path, Path]:
|
||||
md_path = output_dir / "index.md"
|
||||
html_path = output_dir / "index.html"
|
||||
md_lines = [
|
||||
"# Cache Validation Bundle",
|
||||
"",
|
||||
"This bundle is reproducible on another machine with local Claude transcript data in `~/.claude/projects`.",
|
||||
"",
|
||||
"## Configuration",
|
||||
"",
|
||||
f"- root: `{args.root}`",
|
||||
f"- output dir: `{args.output_dir}`",
|
||||
f"- recent turns per session: `{args.recent_turns_per_session}`",
|
||||
f"- workers: `{args.workers}`",
|
||||
f"- cache TTL minutes: `{args.cache_ttl_minutes}`",
|
||||
f"- cache write multiplier: `{args.cache_write_multiplier}`",
|
||||
f"- max real processed events per mode: `{args.max_real_events_per_mode}`",
|
||||
f"- include transcript content: `{args.include_content}`",
|
||||
"",
|
||||
"## Reproducibility",
|
||||
"",
|
||||
f"- git sha: `{metadata['git_sha']}`",
|
||||
f"- git dirty: `{metadata['git_dirty']}`",
|
||||
f"- python: `{metadata['implementation']}`",
|
||||
f"- platform: `{metadata['platform']}`",
|
||||
f"- corpus session file count: `{corpus['session_file_count']}`",
|
||||
f"- corpus fingerprint: `{corpus['session_files_sha256']}`",
|
||||
"",
|
||||
"## Real Corpus Summary",
|
||||
"",
|
||||
f"- projects: `{dataset['projects']}`",
|
||||
f"- sessions: `{dataset['sessions']}`",
|
||||
f"- requests: `{dataset['requests']}`",
|
||||
f"- observed total cost: `{format_currency(observed['total_cost_usd'])}`",
|
||||
f"- winner by total cost: `{winners['total_cost']}`",
|
||||
"",
|
||||
"| Mode | Total Cost | Cache Busts | Busting Rewrites | Stable Replay Rewrites | Rewrites | Retroactive Rewrites | TTL Expiry | Forwarded Tokens |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
]
|
||||
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
||||
summary = summaries[mode]
|
||||
md_lines.append(
|
||||
f"| `{mode}` | {format_currency(summary['total_cost_usd'])} | {summary['cache_bust_turns']} | "
|
||||
f"{summary['busting_rewrite_turns']} | {summary['stable_replay_rewrite_turns']} | "
|
||||
f"{summary['rewrite_turns']} | {summary['retroactive_rewrite_turns']} | "
|
||||
f"{summary['ttl_expiry_turns']} | {summary['forwarded_input_tokens']:,} |"
|
||||
)
|
||||
md_lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Interpretation",
|
||||
"",
|
||||
"- `cache_bust_turns` and `busting_rewrite_turns` are the hard-failure metrics for Anthropic prefix caching.",
|
||||
"- `stable_replay_rewrite_turns` indicates replay of previously-forwarded bytes that still preserves cache prefix stability.",
|
||||
"- `retroactive_rewrite_turns` is descriptive only; it does not imply a cache break by itself.",
|
||||
"- `ttl_expiry_turns` is workload timing context, not compression correctness.",
|
||||
"",
|
||||
"## Artifacts",
|
||||
"",
|
||||
f"- real corpus summary markdown: [real/{real_bench.OUTPUT_MD}](real/{real_bench.OUTPUT_MD})",
|
||||
f"- real corpus summary html: [real/{real_bench.OUTPUT_HTML}](real/{real_bench.OUTPUT_HTML})",
|
||||
f"- real processed markdown: [real_processed/{processed_paths[0].name}](real_processed/{processed_paths[0].name})",
|
||||
f"- real processed html: [real_processed/{processed_paths[2].name}](real_processed/{processed_paths[2].name})",
|
||||
f"- synthetic token bust markdown: [synthetic_token_bust/{token_bust_paths[0].name}](synthetic_token_bust/{token_bust_paths[0].name})",
|
||||
f"- synthetic token bust html: [synthetic_token_bust/{token_bust_paths[2].name}](synthetic_token_bust/{token_bust_paths[2].name})",
|
||||
f"- synthetic long suite markdown: [synthetic_long_suite/{long_suite_paths[0].name}](synthetic_long_suite/{long_suite_paths[0].name})",
|
||||
f"- synthetic long suite html: [synthetic_long_suite/{long_suite_paths[2].name}](synthetic_long_suite/{long_suite_paths[2].name})",
|
||||
]
|
||||
)
|
||||
md_path.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
|
||||
rows = []
|
||||
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
||||
summary = summaries[mode]
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><code>{html.escape(mode)}</code></td>"
|
||||
f"<td>{html.escape(format_currency(summary['total_cost_usd']))}</td>"
|
||||
f"<td>{summary['cache_bust_turns']}</td>"
|
||||
f"<td>{summary['busting_rewrite_turns']}</td>"
|
||||
f"<td>{summary['stable_replay_rewrite_turns']}</td>"
|
||||
f"<td>{summary['rewrite_turns']}</td>"
|
||||
f"<td>{summary['retroactive_rewrite_turns']}</td>"
|
||||
f"<td>{summary['ttl_expiry_turns']}</td>"
|
||||
f"<td>{summary['forwarded_input_tokens']:,}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
html_doc = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>Cache Validation Bundle</title>"
|
||||
"<style>"
|
||||
"body{font-family:ui-sans-serif,system-ui,sans-serif;max-width:1200px;margin:40px auto;padding:0 20px;line-height:1.55;color:#111827;background:#f8fafc}"
|
||||
".card{background:white;border:1px solid #cbd5e1;border-radius:16px;padding:24px;margin:18px 0;box-shadow:0 8px 24px rgba(15,23,42,.06)}"
|
||||
"table{border-collapse:collapse;width:100%;margin:16px 0;background:white}"
|
||||
"th,td{border:1px solid #cbd5e1;padding:10px;text-align:left}th{background:#e2e8f0}"
|
||||
"code{background:#e5e7eb;padding:1px 4px;border-radius:4px}"
|
||||
"</style></head><body>"
|
||||
"<h1>Cache Validation Bundle</h1>"
|
||||
"<div class='card'>"
|
||||
f"<p><strong>root</strong>: <code>{html.escape(str(args.root))}</code><br>"
|
||||
f"<strong>recent turns per session</strong>: <code>{html.escape(str(args.recent_turns_per_session))}</code><br>"
|
||||
f"<strong>workers</strong>: <code>{args.workers}</code><br>"
|
||||
f"<strong>cache TTL minutes</strong>: <code>{args.cache_ttl_minutes}</code><br>"
|
||||
f"<strong>include transcript content</strong>: <code>{args.include_content}</code></p>"
|
||||
"</div>"
|
||||
"<div class='card'><h2>Reproducibility</h2>"
|
||||
f"<p><strong>git sha</strong>: <code>{html.escape(str(metadata['git_sha']))}</code><br>"
|
||||
f"<strong>git dirty</strong>: <code>{metadata['git_dirty']}</code><br>"
|
||||
f"<strong>python</strong>: <code>{html.escape(str(metadata['implementation']))}</code><br>"
|
||||
f"<strong>platform</strong>: <code>{html.escape(str(metadata['platform']))}</code><br>"
|
||||
f"<strong>corpus session file count</strong>: <code>{corpus['session_file_count']}</code><br>"
|
||||
f"<strong>corpus fingerprint</strong>: <code>{html.escape(str(corpus['session_files_sha256']))}</code></p>"
|
||||
"</div>"
|
||||
"<div class='card'><h2>Real Corpus Summary</h2>"
|
||||
f"<p>projects: <code>{dataset['projects']}</code><br>"
|
||||
f"sessions: <code>{dataset['sessions']}</code><br>"
|
||||
f"requests: <code>{dataset['requests']}</code><br>"
|
||||
f"observed total cost: <code>{html.escape(format_currency(observed['total_cost_usd']))}</code><br>"
|
||||
f"winner by total cost: <code>{html.escape(winners['total_cost'])}</code></p>"
|
||||
"<table><thead><tr><th>Mode</th><th>Total Cost</th><th>Cache Busts</th><th>Busting Rewrites</th>"
|
||||
"<th>Stable Replay Rewrites</th><th>Rewrites</th>"
|
||||
"<th>Retroactive Rewrites</th><th>TTL Expiry</th><th>Forwarded Tokens</th></tr></thead><tbody>"
|
||||
+ "".join(rows)
|
||||
+ "</tbody></table>"
|
||||
"<p><strong>Interpretation</strong>: <code>cache_bust_turns</code> and "
|
||||
"<code>busting_rewrite_turns</code> are the hard-failure metrics. "
|
||||
"<code>stable_replay_rewrite_turns</code> is acceptable stable replay. "
|
||||
"<code>retroactive_rewrite_turns</code> is descriptive only. "
|
||||
"<code>ttl_expiry_turns</code> is workload timing context.</p></div>"
|
||||
"<div class='card'><h2>Artifacts</h2><ul>"
|
||||
f"<li><a href='real/{real_bench.OUTPUT_HTML}'>Real corpus summary HTML</a></li>"
|
||||
f"<li><a href='real/{real_bench.OUTPUT_MD}'>Real corpus summary Markdown</a></li>"
|
||||
f"<li><a href='real_processed/{processed_paths[2].name}'>Real processed rewrite HTML</a></li>"
|
||||
f"<li><a href='real_processed/{processed_paths[0].name}'>Real processed rewrite Markdown</a></li>"
|
||||
f"<li><a href='synthetic_token_bust/{token_bust_paths[2].name}'>Synthetic token-bust HTML</a></li>"
|
||||
f"<li><a href='synthetic_token_bust/{token_bust_paths[0].name}'>Synthetic token-bust Markdown</a></li>"
|
||||
f"<li><a href='synthetic_long_suite/{long_suite_paths[2].name}'>Synthetic long suite HTML</a></li>"
|
||||
f"<li><a href='synthetic_long_suite/{long_suite_paths[0].name}'>Synthetic long suite Markdown</a></li>"
|
||||
"</ul></div></body></html>"
|
||||
)
|
||||
html_path.write_text(html_doc, encoding="utf-8")
|
||||
return md_path, html_path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=real_bench.DEFAULT_ROOT)
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||
parser.add_argument("--recent-turns-per-session", type=int, default=None)
|
||||
parser.add_argument("--workers", type=int, default=1)
|
||||
parser.add_argument(
|
||||
"--cache-ttl-minutes", type=int, default=real_bench.DEFAULT_CACHE_TTL_MINUTES
|
||||
)
|
||||
parser.add_argument("--cache-write-multiplier", type=float, default=1.25)
|
||||
parser.add_argument("--max-sessions", type=int, default=None)
|
||||
parser.add_argument("--max-real-events-per-mode", type=int, default=8)
|
||||
parser.add_argument("--content-excerpt-chars", type=int, default=220)
|
||||
parser.add_argument(
|
||||
"--include-content",
|
||||
action="store_true",
|
||||
help="Include real transcript-derived content excerpts in the processed event reports.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-dir",
|
||||
type=Path,
|
||||
default=real_bench.DEFAULT_OUTPUT_DIR / real_bench.CHECKPOINT_DIRNAME,
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
output_dir = args.output_dir
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.getLogger("headroom.transforms").setLevel(logging.WARNING)
|
||||
logging.getLogger("headroom.proxy").setLevel(logging.WARNING)
|
||||
|
||||
session_files = select_session_files(args.root, max_sessions=args.max_sessions)
|
||||
if not session_files:
|
||||
print(f"No Claude session replays found under {args.root}")
|
||||
return 1
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
metadata = _runtime_metadata(repo_root)
|
||||
corpus = _corpus_fingerprint(
|
||||
root=args.root,
|
||||
session_files=session_files,
|
||||
max_sessions=args.max_sessions,
|
||||
recent_turns_per_session=args.recent_turns_per_session,
|
||||
cache_ttl_minutes=args.cache_ttl_minutes,
|
||||
)
|
||||
dataset, observed = build_dataset_and_observed_from_files(
|
||||
session_files,
|
||||
cache_write_multiplier=args.cache_write_multiplier,
|
||||
recent_turns_per_session=args.recent_turns_per_session,
|
||||
)
|
||||
checkpoint_base = output_dir / "checkpoints" / corpus["session_files_sha256"]
|
||||
checkpoint_dir = resolve_checkpoint_dir(
|
||||
checkpoint_base,
|
||||
recent_turns_per_session=args.recent_turns_per_session,
|
||||
cache_ttl_minutes=args.cache_ttl_minutes,
|
||||
)
|
||||
|
||||
real_output_dir = output_dir / "real"
|
||||
summaries = simulate_session_files(
|
||||
session_files,
|
||||
dataset,
|
||||
cache_ttl_minutes=args.cache_ttl_minutes,
|
||||
cache_write_multiplier=args.cache_write_multiplier,
|
||||
workers=args.workers,
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
recent_turns_per_session=args.recent_turns_per_session,
|
||||
)
|
||||
real_md, real_json, real_html = write_report(real_output_dir, dataset, observed, summaries)
|
||||
|
||||
processed_payload = _collect_real_processed_events(
|
||||
root=args.root,
|
||||
recent_turns_per_session=args.recent_turns_per_session,
|
||||
max_events_per_mode=args.max_real_events_per_mode,
|
||||
ttl_minutes=args.cache_ttl_minutes,
|
||||
max_chars=args.content_excerpt_chars,
|
||||
include_content=args.include_content,
|
||||
)
|
||||
processed_paths = _write_processed_event_reports(output_dir, processed_payload)
|
||||
|
||||
token_bust.OUTPUT_DIR = output_dir / "synthetic_token_bust"
|
||||
token_bust_replay = token_bust._build_replay()
|
||||
original_make_proxy = token_bust.bench._make_proxy
|
||||
token_bust.bench._make_proxy = lambda mode: token_bust._FakeProxy()
|
||||
try:
|
||||
_, token_bust_summaries = token_bust.simulate_replays(
|
||||
[token_bust_replay],
|
||||
cache_ttl_minutes=token_bust.TTL_MINUTES if hasattr(token_bust, "TTL_MINUTES") else 5,
|
||||
)
|
||||
token_bust_events = token_bust._build_bust_events(token_bust_replay)
|
||||
finally:
|
||||
token_bust.bench._make_proxy = original_make_proxy
|
||||
token_bust_paths = token_bust._write_report(
|
||||
token_bust_replay,
|
||||
token_bust_summaries,
|
||||
determine_winners(token_bust_summaries),
|
||||
token_bust_events,
|
||||
)
|
||||
|
||||
long_suite.OUTPUT_DIR = output_dir / "synthetic_long_suite"
|
||||
per_scenario, aggregate = long_suite._run_suite()
|
||||
long_suite_paths = long_suite._write_report(per_scenario, aggregate)
|
||||
|
||||
bundle_payload = {
|
||||
"config": {
|
||||
"root": _redact_path(str(args.root.resolve())),
|
||||
"output_dir": _redact_path(str(output_dir.resolve())),
|
||||
"recent_turns_per_session": args.recent_turns_per_session,
|
||||
"workers": args.workers,
|
||||
"cache_ttl_minutes": args.cache_ttl_minutes,
|
||||
"cache_write_multiplier": args.cache_write_multiplier,
|
||||
"max_sessions": args.max_sessions,
|
||||
"max_real_events_per_mode": args.max_real_events_per_mode,
|
||||
"content_excerpt_chars": args.content_excerpt_chars,
|
||||
"include_content": args.include_content,
|
||||
"checkpoint_dir": _redact_path(str(checkpoint_dir.resolve())),
|
||||
},
|
||||
"runtime": metadata,
|
||||
"corpus": corpus,
|
||||
"real": {
|
||||
"dataset": asdict(dataset),
|
||||
"observed": asdict(observed),
|
||||
"summaries": {mode: asdict(summary) for mode, summary in summaries.items()},
|
||||
"winners": determine_winners(summaries),
|
||||
"paths": {
|
||||
"markdown": str(real_md),
|
||||
"json": str(real_json),
|
||||
"html": str(real_html),
|
||||
},
|
||||
},
|
||||
"processed_real": {
|
||||
"events": processed_payload["events"],
|
||||
"paths": {
|
||||
"markdown": str(processed_paths[0]),
|
||||
"json": str(processed_paths[1]),
|
||||
"html": str(processed_paths[2]),
|
||||
},
|
||||
},
|
||||
"synthetic_token_bust": {
|
||||
"paths": {
|
||||
"markdown": str(token_bust_paths[0]),
|
||||
"json": str(token_bust_paths[1]),
|
||||
"html": str(token_bust_paths[2]),
|
||||
}
|
||||
},
|
||||
"synthetic_long_suite": {
|
||||
"paths": {
|
||||
"markdown": str(long_suite_paths[0]),
|
||||
"json": str(long_suite_paths[1]),
|
||||
"html": str(long_suite_paths[2]),
|
||||
}
|
||||
},
|
||||
}
|
||||
manifest_path = output_dir / "bundle_manifest.json"
|
||||
manifest_path.write_text(json.dumps(bundle_payload, indent=2), encoding="utf-8")
|
||||
|
||||
index_md, index_html = _write_index(
|
||||
output_dir,
|
||||
args=args,
|
||||
dataset=asdict(dataset),
|
||||
observed=asdict(observed),
|
||||
summaries={mode: asdict(summary) for mode, summary in summaries.items()},
|
||||
winners=determine_winners(summaries),
|
||||
metadata=metadata,
|
||||
corpus=corpus,
|
||||
processed_paths=processed_paths,
|
||||
token_bust_paths=token_bust_paths,
|
||||
long_suite_paths=long_suite_paths,
|
||||
)
|
||||
|
||||
print(f"Index markdown: {index_md}")
|
||||
print(f"Index html: {index_html}")
|
||||
print(f"Manifest: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user