Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	crates/headroom-proxy/src/config.rs
This commit is contained in:
Jerrett Davis 2026-08-14 17:07:37 -05:00
commit b352d5eed4
557 changed files with 48789 additions and 15559 deletions

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.31.0"
"version": "0.35.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.31.0",
"version": "0.35.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

@ -1,7 +1,7 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"body-max-line-length": [2, "always", 200],
"body-max-line-length": [0],
"footer-leading-blank": [0],
"subject-case": [0],
"type-enum": [
@ -12,6 +12,7 @@
"chore",
"ci",
"docs",
"deps",
"feat",
"fix",
"parity",
@ -23,4 +24,4 @@
]
]
}
}
}

View file

@ -40,6 +40,16 @@ Closes #
- Observed result:
- Not tested:
## Runtime Rollout Safety
- Rollout-managed feature(s):
- Minimum rollout channel:
- Stable/default behavior changed:
- Kill switch / disable path:
- Unsafe override required:
- Qualification impact:
- Rollback path:
## Review Readiness
- [ ] I have performed a self-review

View file

@ -1,19 +1,13 @@
{
"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"
}
}
{
"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\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\n## Testing\n\n- [x] Unit tests pass (`pytest`)\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 and ran governance.\n- Observed result: The check passed with complete facts.\n- Not tested: Repository settings.\n\n## Runtime Rollout Safety\n\n- Rollout-managed feature(s): None.\n- Minimum rollout channel: Stable.\n- Stable/default behavior changed: No.\n- Kill switch / disable path: Not applicable.\n- Unsafe override required: No.\n- Qualification impact: None.\n- Rollback path: Revert the workflow and script changes.\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"}
}

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.31.0"
"version": "0.35.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.31.0",
"version": "0.35.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View file

@ -70,7 +70,6 @@ jobs:
- 'headroom/cli/**'
- 'headroom/install/**'
- 'headroom/providers/**'
- 'headroom/rtk/**'
- 'crates/**'
- '**/*.rs'
- 'Cargo.toml'
@ -294,6 +293,7 @@ jobs:
uses: codecov/codecov-action@v5
with:
files: coverage-${{ matrix.shard }}.xml
disable_search: true
flags: python
name: python-shard-${{ matrix.shard }}
# Token is sent so uploads authenticate once the repo is activated on
@ -561,7 +561,7 @@ jobs:
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install pytest 'opentelemetry-api>=1.24.0'
- name: Run native installer wrapper tests
run: pytest tests/test_install/test_native_installers.py -q
@ -579,7 +579,7 @@ jobs:
run: |
brew install bash
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest
python -m pip install --retries 10 --timeout 60 pytest 'opentelemetry-api>=1.24.0'
- name: Run native installer wrapper tests
run: |
BASH_PREFIX="$(brew --prefix bash)"

View file

@ -204,10 +204,10 @@ jobs:
- 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.
# Variant + arch uniquely identify the marker. The manifest job
# downloads both architecture artifacts by exact name; a glob such
# as `digests-code-*` would also match code-nonroot/code-slim.
# `root` substitutes the empty-string variant.
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
@ -220,6 +220,7 @@ jobs:
# tags, and that manifest is what users pull by `:tag`.
docker-manifest:
needs: docker-build
if: ${{ always() }}
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
@ -272,12 +273,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download per-arch digests for this variant
- name: Download amd64 digest for this variant
uses: actions/download-artifact@v8
with:
pattern: digests-${{ matrix.variant.name || 'root' }}-*
name: digests-${{ matrix.variant.name || 'root' }}-amd64
path: ${{ runner.temp }}/digests
- name: Download arm64 digest for this variant
uses: actions/download-artifact@v8
with:
name: digests-${{ matrix.variant.name || 'root' }}-arm64
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,
@ -313,6 +319,11 @@ jobs:
echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2
exit 1
fi
digest_count="$(find "${DIGEST_DIR}" -maxdepth 1 -type f | wc -l)"
if [ "${digest_count}" -ne 2 ]; then
echo "ERROR: expected both architecture digests for variant '${{ matrix.variant.name || 'root' }}', found ${digest_count}" >&2
exit 1
fi
digest_refs=()
for f in "${DIGEST_DIR}"/*; do
digest="$(basename "$f")"
@ -382,53 +393,13 @@ jobs:
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 != ''
if: steps.manifest.outputs.index_digest != '' && matrix.variant.name == '' && 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).
# Add a unique annotation so GHCR records a fresh root package version.
promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker buildx imagetools create \
--annotation "index:io.headroom.promoted-at=${promoted_at}" \

View file

@ -1,19 +1,27 @@
name: Deploy Documentation
name: Validate Docs
# There is ONE documentation site: the Next.js/Fumadocs app in `docs/`, published
# at https://headroom-docs.vercel.app by Vercel's own Git integration. That URL is
# what README and `pyproject.toml` (Homepage, Documentation) point at.
#
# This workflow therefore only *validates* — it deploys nothing. Vercel owns
# deployment; duplicating it here is what produced a `deploy-vercel` job that
# failed 30 times on main without ever deploying (no VERCEL_* secrets were set).
#
# A second site used to be built from `wiki/` by MkDocs and published to GitHub
# Pages off the `gh-pages` branch. It was linked from nowhere in the repo, it meant
# every documented change had to be written twice, and each Pages deploy
# force-pushed `gh-pages` — which Vercel then tried to build, failing with
# "The specified Root Directory 'docs' does not exist" because that branch holds
# only the rendered site. Removed. `wiki/` stays in the repo as unpublished
# markdown pending migration of the pages `docs/` does not yet cover (notably
# `wiki/cli.md`); nothing builds or publishes it, so it needs no syncing.
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:
@ -21,35 +29,8 @@ permissions:
contents: read
jobs:
validate-mkdocs:
name: Validate mkdocs build
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
validate-nextjs:
name: Validate Next.js build
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@ -69,62 +50,3 @@ jobs:
- name: Build docs
run: npm run build
working-directory: docs
deploy-github-pages:
name: Deploy GitHub Pages
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
deploy-vercel:
name: Deploy Vercel Docs
if: >-
github.event_name != 'pull_request'
&& github.ref == 'refs/heads/main'
&& github.repository_owner == 'headroomlabs-ai'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: docs
- name: Deploy to Vercel
run: npx vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
working-directory: docs
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

View file

@ -11,12 +11,14 @@ on:
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- "headroom/providers/opencode/hook-shim/**"
- ".github/workflows/opencode-plugin.yml"
push:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- "headroom/providers/opencode/hook-shim/**"
- ".github/workflows/opencode-plugin.yml"
permissions:
@ -51,3 +53,6 @@ jobs:
cmp dist-standalone/entry.opencode.js \
../../headroom/providers/opencode/_dist/entry.opencode.js \
|| { echo "::error::headroom/providers/opencode/_dist/entry.opencode.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }
cmp dist-standalone/hook-shim/handler.js \
../../headroom/providers/opencode/hook-shim/handler.js \
|| { echo "::error::headroom/providers/opencode/hook-shim/handler.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }

View file

@ -0,0 +1,87 @@
name: Release Metadata Sync
# Keep generated version-carrying files in sync on release-please's branch.
#
# Why this exists
# ---------------
# release-please only rewrites `pyproject.toml` plus the `extra-files` listed in
# `.release-please-config.json` (currently the TypeScript SDK and OpenClaw
# package.json). Several other tracked files also carry the version, and
# `server.json` is asserted byte-for-byte against `render_server_json()` — which
# derives its version from `pyproject.toml`. So the moment release-please bumps
# the version, `tests/test_mcp_registry/test_server_json.py::
# test_root_server_json_matches_builder` fails on the release PR, and the release
# cannot be merged. That is what blocked v0.33.0 (PR #2339).
#
# `release.yml` already runs `scripts/version-sync.py` before its own
# `verify-versions.py` gate, so the release *build* self-heals in the workspace.
# The regular CI test job does not, so the fix has to be committed.
#
# Why a workflow rather than more `extra-files` entries
# ----------------------------------------------------
# `scripts/version-sync.py` is the single place that knows every version-carrying
# file. Restating that list as per-file jsonpaths would duplicate it, and a
# jsonpath that silently fails to match produces exactly the failure we are trying
# to remove. Running the script instead means files added to it in future are
# covered with no change here.
#
# Why the push trigger
# --------------------
# release-please regenerates (force-pushes) its branch on every merge to main.
# That is what repeatedly wiped the hand-pushed metadata fixes on #2339. Keying
# off a push to the branch means the sync re-applies after every regeneration
# instead of being lost.
on:
push:
branches:
- "release-please--branches--**"
permissions:
contents: write
concurrency:
# Never cancel: a half-applied sync would leave the release PR inconsistent.
group: release-metadata-sync-${{ github.ref }}
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref_name }}
# PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one:
# a push made with GITHUB_TOKEN does not trigger workflows, so the release
# PR's checks would never re-run against the synced commit and would stay
# red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual
# re-run of the PR's checks picks it up.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@v6
with:
python-version: "3.12"
# version-sync.py is stdlib-only (json/re/tomllib), so no install step.
- name: Sync version-carrying files release-please does not bump
run: python scripts/version-sync.py
- name: Verify all versions agree
run: python scripts/verify-versions.py
- name: Commit and push if anything changed
run: |
if git diff --quiet; then
echo "Already in sync — nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore: sync generated version metadata"
# This push re-triggers this workflow. version-sync.py is idempotent, so
# the next run finds no diff and exits above without pushing — the loop
# terminates after one no-op run.
git push origin HEAD:"${GITHUB_REF_NAME}"

View file

@ -12,6 +12,7 @@ env:
NPM_REGISTRY_URL: https://registry.npmjs.org
NPM_SDK_PACKAGE: headroom-ai
NPM_OPENCLAW_PACKAGE: headroom-openclaw
NPM_OPENCODE_PACKAGE: headroom-opencode
# GitHub Package Registry
GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com
@ -852,10 +853,30 @@ jobs:
npm publish --access public
continue-on-error: true
- name: npm publish notice
if: steps.npm-sdk-publish.outcome == 'failure' || steps.npm-openclaw-publish.outcome == 'failure'
- name: Publish ${{ env.NPM_OPENCODE_PACKAGE }} to npmjs.org
id: npm-opencode-publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "::notice::One or more npm publishes failed. Set NPM_SKIP=true in repo Variables to skip both npm publishes if tokens are not configured."
version="${{ needs.detect-version.outputs.npm_version }}"
cd plugins/opencode
npm ci
npm run build
npm version "$version" --no-git-tag-version --allow-same-version
HEADROOM_NPM_VERSION="$version" node <<'EOF'
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg.dependencies = pkg.dependencies || {};
pkg.dependencies["headroom-ai"] = `^${process.env.HEADROOM_NPM_VERSION}`;
fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`);
EOF
npm publish --access public
continue-on-error: true
- name: npm publish notice
if: steps.npm-sdk-publish.outcome == 'failure' || steps.npm-openclaw-publish.outcome == 'failure' || steps.npm-opencode-publish.outcome == 'failure'
run: |
echo "::notice::One or more npm publishes failed. Set NPM_SKIP=true in repo Variables to skip npm publishes if tokens are not configured."
publish-github-packages:
needs: [detect-version, build]

View file

@ -9,7 +9,6 @@ on:
paths:
- 'headroom/cli/**'
- 'headroom/providers/**'
- 'headroom/rtk/**'
- 'crates/**'
- 'docker/**'
- 'Dockerfile'

View file

@ -16,7 +16,6 @@ on:
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"

View file

@ -27,7 +27,7 @@ repos:
# unconditionally, so installing hooks is not required for enforcement.
args: [--assume-in-merge]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.17
rev: v0.16.2
hooks:
- id: ruff
args: [--fix]

View file

@ -7,8 +7,8 @@
"bump-patch-for-minor-pre-major": false,
"draft": false,
"prerelease": false,
"separate-pull-requests": false,
"pull-request-title-pattern": "chore: release ${version}",
"separate-pull-requests": true,
"pull-request-title-pattern": "chore: release${component} ${version}",
"packages": {
".": {
"package-name": "headroom-ai",
@ -23,6 +23,11 @@
"type": "json",
"path": "plugins/openclaw/package.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "plugins/opencode/package.json",
"jsonpath": "$.version"
}
]
}

View file

@ -1,3 +1,3 @@
{
".": "0.32.0"
".": "0.35.0"
}

View file

@ -1,9 +1,10 @@
{
"version": "0.31.0",
"version": "0.35.0",
"packages": {
"pypi": "0.31.0",
"npm-sdk": "0.31.0",
"npm-openclaw": "0.31.0",
"agent-hooks-plugin": "0.31.0"
"pypi": "0.35.0",
"npm-sdk": "0.35.0",
"npm-openclaw": "0.35.0",
"npm-opencode": "0.35.0",
"agent-hooks-plugin": "0.35.0"
}
}

View file

@ -1,38 +1,5 @@
# 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 name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "headroom"
# 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
@ -55,23 +22,19 @@ 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.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
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 **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
@ -131,3 +94,76 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# list of additional workspace folder paths for cross-package reference support.
# 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, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart elixir
# elm erlang fortran fsharp gdscript
# go groovy haskell haxe hlsl
# html java json julia kotlin
# latex lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor php_phpantom powershell
# python python_basedpyright python_jedi python_pyrefly python_ty
# qml 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; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several 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 language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server 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.
language_servers:
- python
- rust
- typescript
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0

View file

@ -284,6 +284,297 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **code:** fix two `CodeAwareCompressor` AST-reassembly bugs: an exported JS/TS function or class (`export function foo() {`) produced a duplicated `export export` keyword and invalid syntax, because line-based node slicing (used to preserve indentation) pulled in the preceding `export` sibling's text on top of the `export_statement` handler's own prefix reconstruction. Separately, in every supported language, a doc comment immediately above a top-level function, class, or type was detached from its declaration during extraction and re-emitted in a cluster at the end of the compressed output instead of staying attached to what it documents.
- * **proxy:** Buffered upstream responses containing a `server_tool_use` (or any other unrecognized Anthropic content block) no longer turn a fully-generated response into an HTTP 502. `StreamingMixin._response_to_sse` raised `ValueError` on unknown block types after the entire upstream generation had already been buffered, so a slow-but-successful response failed and the client retried the whole multi-minute request. Unknown blocks are now emitted verbatim in `content_block_start` (following the existing redacted_thinking` pattern), so `server_tool_use`, `server_tool_result`, `mcp_tool_use`, and future block types round-trip ([#1806](https://github.com/headroomlabs-ai/headroom/issues/1806)).
## [0.35.0](https://github.com/headroomlabs-ai/headroom/compare/v0.34.0...v0.35.0) (2026-08-12)
### Features
* **beacon:** allowlist the routing summary key ([#2818](https://github.com/headroomlabs-ai/headroom/issues/2818)) ([7940c05](https://github.com/headroomlabs-ai/headroom/commit/7940c05ebf4486c6b9d00984067ae33cedf4dddb))
* **beacon:** hourly R2 compaction, per-strategy savings, and a stack that reports ([#2853](https://github.com/headroomlabs-ai/headroom/issues/2853)) ([e0870ef](https://github.com/headroomlabs-ai/headroom/commit/e0870ef931e5ea6cc6cb52551f5d80cd9e3dc715))
* **cli,pricing:** add CLI extension seam and prompt-cache TTL pricing ([#2802](https://github.com/headroomlabs-ai/headroom/issues/2802)) ([6ec3e34](https://github.com/headroomlabs-ai/headroom/commit/6ec3e3478abf058fe1460f91342bcdadf54a1ba8))
### Bug Fixes
* **anthropic:** strip first-party tool search on custom upstreams ([#2539](https://github.com/headroomlabs-ai/headroom/issues/2539)) ([7f6950b](https://github.com/headroomlabs-ai/headroom/commit/7f6950be34e29304deae0fa5138b852491b092fe))
* **backends/anyllm:** convert Anthropic tools and tool_choice to OpenAI shape ([0d6866b](https://github.com/headroomlabs-ai/headroom/commit/0d6866b91a3777475abd58cd8b63a10cd0621e7f))
* **backends/anyllm:** stream tool_use blocks and map finish_reason on the streaming path ([e4904e2](https://github.com/headroomlabs-ai/headroom/commit/e4904e23a6ba6f5cff2488332946481172446922))
* **backends/litellm:** None-guard core token counts in OpenAI usage block ([#2324](https://github.com/headroomlabs-ai/headroom/issues/2324)) ([12f9f58](https://github.com/headroomlabs-ai/headroom/commit/12f9f58cb3dcfc67af1238424d404d8dd9bad1dd))
* **beacon:** report all-layers savings, not context-compression only ([#2796](https://github.com/headroomlabs-ai/headroom/issues/2796)) ([e9a24f3](https://github.com/headroomlabs-ai/headroom/commit/e9a24f3ec1ffd278b0b3ca547a90942c40c99ec8))
* **beacon:** split session failures by status code ([#2815](https://github.com/headroomlabs-ai/headroom/issues/2815)) ([2954e37](https://github.com/headroomlabs-ai/headroom/commit/2954e37048f8dcffe16e1c37b8f71afb0094a0a2))
* **cache:** bound compression cache bookkeeping ([0ae948c](https://github.com/headroomlabs-ai/headroom/commit/0ae948c1510735df39317bf0861f8a8750cdbf9d))
* **cache:** enforce Anthropic's 1h-before-5m cache_control ordering before forwarding ([#2941](https://github.com/headroomlabs-ai/headroom/issues/2941)) ([3752458](https://github.com/headroomlabs-ai/headroom/commit/3752458022f736c779f7b5a6c2d6d2ef0bc89f72))
* **cache:** mirror client cache_control positions instead of single-marker consolidation ([def3d76](https://github.com/headroomlabs-ai/headroom/commit/def3d76e5ab4665e609b51bfba54dd6d25116925))
* **cache:** stabilize Anthropic block-growing lineages ([#2917](https://github.com/headroomlabs-ai/headroom/issues/2917)) ([1a04c95](https://github.com/headroomlabs-ai/headroom/commit/1a04c957f53ef25ab1209166f425a7876913c4d3))
* **ccr:** avoid injecting tool on chat streaming ([d0c1f5b](https://github.com/headroomlabs-ai/headroom/commit/d0c1f5b8ad68c7a44ed3aaa0fe40e3a656950123))
* **ccr:** preserve exact SQLite TTL boundary ([#2669](https://github.com/headroomlabs-ai/headroom/issues/2669)) ([d0a86d4](https://github.com/headroomlabs-ai/headroom/commit/d0a86d409fab377f9c642d1f3680b6ece7f97b8a))
* **ccr:** report embedded hashes from compress endpoint ([#717](https://github.com/headroomlabs-ai/headroom/issues/717)) ([685ebe4](https://github.com/headroomlabs-ai/headroom/commit/685ebe457d727922ba4057515556a2d2aac0f616))
* **ccr:** resolve &lt;&lt;ccr:...&gt;&gt; markers inline when no retrieve-tool path exists ([#2512](https://github.com/headroomlabs-ai/headroom/issues/2512)) ([ce8ce83](https://github.com/headroomlabs-ai/headroom/commit/ce8ce8313f8cebf060392a62f9adaab18c0df386))
* **ccr:** tolerate null/malformed OpenAI data in response handling ([#2467](https://github.com/headroomlabs-ai/headroom/issues/2467)) ([e583e08](https://github.com/headroomlabs-ai/headroom/commit/e583e082d8dee942229ac6211c742f9c9448a905))
* **ci:** publish latest from the root Docker manifest ([#2252](https://github.com/headroomlabs-ai/headroom/issues/2252)) ([5568d73](https://github.com/headroomlabs-ai/headroom/commit/5568d738afb5e080d8df56e64500026996cbf025))
* **claude:** stop forcing tool search on Foundry ([#2477](https://github.com/headroomlabs-ai/headroom/issues/2477)) ([7981396](https://github.com/headroomlabs-ai/headroom/commit/798139608c0fb5118eb3a7a183b8b2abe92341f1))
* **cli/update:** let install ownership win over bare /.dockerenv so venv installs self-update ([#2830](https://github.com/headroomlabs-ai/headroom/issues/2830)) ([7092b53](https://github.com/headroomlabs-ai/headroom/commit/7092b53c466bf5dbda8a1cda88403d1a4b16deb1))
* **codex:** route alpha search through the Codex backend ([#2538](https://github.com/headroomlabs-ai/headroom/issues/2538)) ([a540eb2](https://github.com/headroomlabs-ai/headroom/commit/a540eb2c61b1a47e5ab8b07ea4a80fee780b6514))
* **content-router:** protect custom-tag blocks before mixed-content section split ([d7bc1e2](https://github.com/headroomlabs-ai/headroom/commit/d7bc1e275f411788abffa2d007db14aa17fd31c5))
* **deps:** bump h2 to 4.4.1 for CVE-2026-71554 ([#2839](https://github.com/headroomlabs-ai/headroom/issues/2839)) ([564e0a8](https://github.com/headroomlabs-ai/headroom/commit/564e0a8d0fe440dff21a6c405c88e05698b3059f))
* **deps:** enforce audited transitive dependency floors ([#2791](https://github.com/headroomlabs-ai/headroom/issues/2791)) ([64e2039](https://github.com/headroomlabs-ai/headroom/commit/64e203931b9810e5a010f063d26d154419016f86))
* **doctor:** flag `ollama launch claude` proxy bypass instead of misdirecting ([#2566](https://github.com/headroomlabs-ai/headroom/issues/2566)) ([7f24d69](https://github.com/headroomlabs-ai/headroom/commit/7f24d695eea00b9bb3265fbaa6629acf0c2ff181))
* emit SSE ping before message_start on Bedrock streaming path (issue [#902](https://github.com/headroomlabs-ai/headroom/issues/902)) ([#1080](https://github.com/headroomlabs-ai/headroom/issues/1080)) ([4dab254](https://github.com/headroomlabs-ai/headroom/commit/4dab254d52914c39ffe13071848604e1771b1bd1))
* **gemini:** resolve native CCR retrieval calls ([#2253](https://github.com/headroomlabs-ai/headroom/issues/2253)) ([2483f57](https://github.com/headroomlabs-ai/headroom/commit/2483f570025763cd9183a93749ea8cf38f1aeb85))
* **health:** label kompress as degraded/optional when not yet loaded ([#2865](https://github.com/headroomlabs-ai/headroom/issues/2865)) ([8949371](https://github.com/headroomlabs-ai/headroom/commit/89493714d2cffdc1f81a8f417ea09891453d7009))
* **image:** decouple routing types from trained_router so importing the compressor doesn't import torch ([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513)) ([#2537](https://github.com/headroomlabs-ai/headroom/issues/2537)) ([d7cf981](https://github.com/headroomlabs-ai/headroom/commit/d7cf981093cf505192a3736dadd0254a120830a1))
* **install/windows:** register persistent-task from S4U hidden XML ([#2453](https://github.com/headroomlabs-ai/headroom/issues/2453)) ([#2459](https://github.com/headroomlabs-ai/headroom/issues/2459)) ([1edaeb8](https://github.com/headroomlabs-ai/headroom/commit/1edaeb8b76f6b872a6c810d404c944caf1a594b2))
* **install:** don't crash the PowerShell installer when $PROFILE is unset ([#2469](https://github.com/headroomlabs-ai/headroom/issues/2469)) ([fc5c4e2](https://github.com/headroomlabs-ai/headroom/commit/fc5c4e239ce32f2b90a6777772a01bdf49c66cb6))
* **install:** trust Docker bridge for dashboard metadata ([e044139](https://github.com/headroomlabs-ai/headroom/commit/e044139001680fd5198147bf373df6f00db32cc7))
* **install:** use --userns=keep-id under Podman so bind-mount writes don't fail ([#2846](https://github.com/headroomlabs-ai/headroom/issues/2846)) ([3488f8d](https://github.com/headroomlabs-ai/headroom/commit/3488f8d4b5fae4eab157e0c4031ccf712bcbcc0d))
* **learn/gemini:** stop double-counting session tokens ([#2230](https://github.com/headroomlabs-ai/headroom/issues/2230)) ([29d8a5e](https://github.com/headroomlabs-ai/headroom/commit/29d8a5e563cf16dbd3a53a1571f4f352e61e1b33))
* **learn/grok:** detect a Windows absolute project path ([#2283](https://github.com/headroomlabs-ai/headroom/issues/2283)) ([e240df2](https://github.com/headroomlabs-ai/headroom/commit/e240df2b698e601324b85956bd93cb304f6030ab))
* **learn:** stop classifying a successful exit code 0 as an error ([#2289](https://github.com/headroomlabs-ai/headroom/issues/2289)) ([a24fe7d](https://github.com/headroomlabs-ai/headroom/commit/a24fe7dcbfe5ab30d0cef631c936e2245c12d123))
* **litellm:** add async_post_call_success_hook to HeadroomCallback ([#1322](https://github.com/headroomlabs-ai/headroom/issues/1322)) ([3107994](https://github.com/headroomlabs-ai/headroom/commit/3107994aed5fd42e713d3c26f3f08121a62b980e))
* **litellm:** don't forward a caller key the target cannot accept ([#2883](https://github.com/headroomlabs-ai/headroom/issues/2883)) ([2f2950a](https://github.com/headroomlabs-ai/headroom/commit/2f2950a626cebf851aac29255e7188fbb1639f5a))
* **memory:** bound the TrafficLearner pending-pattern accumulator (memory leak) ([#2579](https://github.com/headroomlabs-ai/headroom/issues/2579)) ([1f5feff](https://github.com/headroomlabs-ai/headroom/commit/1f5fefffd3e82c73bddd928cfd53334031e807bc))
* **memory:** close DirectMem0 resources ([6596182](https://github.com/headroomlabs-ai/headroom/commit/65961827cf5e90d7b4e7026feb89aac000a73ea3))
* **memory:** close MCP backend on shutdown ([4bd8ecd](https://github.com/headroomlabs-ai/headroom/commit/4bd8ecd1e31475365801791d35630f66f7393553))
* **memory:** don't crash inline memory extraction on a non-object &lt;memory&gt; block ([#2470](https://github.com/headroomlabs-ai/headroom/issues/2470)) ([e00c6ff](https://github.com/headroomlabs-ai/headroom/commit/e00c6ff81ce2003e04042b8f2d1bd6aa3c6e885c))
* **memory:** keep vector metadata in sync ([#2295](https://github.com/headroomlabs-ai/headroom/issues/2295)) ([c471800](https://github.com/headroomlabs-ai/headroom/commit/c471800e8ee22986c308464b02a85da5575f34cc))
* **memory:** make explicit-project and user store keys collision-resistant ([#2231](https://github.com/headroomlabs-ai/headroom/issues/2231)) ([f840d5f](https://github.com/headroomlabs-ai/headroom/commit/f840d5f2fe938432e542c3f71f2218eeecd06b05))
* **memory:** skip &lt;system-reminder&gt; blocks when building the retrieval query ([#2195](https://github.com/headroomlabs-ai/headroom/issues/2195)) ([#2541](https://github.com/headroomlabs-ai/headroom/issues/2541)) ([4e5a67a](https://github.com/headroomlabs-ai/headroom/commit/4e5a67a342be4be659b62c7863a9e72422605788))
* **memory:** sync FTS5 and vector indexes on CLI delete/edit/prune/purge ([fd4628d](https://github.com/headroomlabs-ai/headroom/commit/fd4628d82156c65d4fa22df9513315790a6cd2fb))
* **oauth2:** make repository lint checks pass ([c85abf7](https://github.com/headroomlabs-ai/headroom/commit/c85abf7a87920012e01f0a677f6fbd98c4b08de0))
* **observability:** aggregate tool savings in OTEL ([#2936](https://github.com/headroomlabs-ai/headroom/issues/2936)) ([941c25d](https://github.com/headroomlabs-ai/headroom/commit/941c25d31e6c6e0b436c307cbe212771ff76b45f))
* **onnx:** stop ONNX thread pools from spinning idle cores ([#2495](https://github.com/headroomlabs-ai/headroom/issues/2495)) ([#2540](https://github.com/headroomlabs-ai/headroom/issues/2540)) ([5c561bd](https://github.com/headroomlabs-ai/headroom/commit/5c561bd913ea60fad2c3c53f4b65e679e7d248d0))
* **openai:** skip Responses tool-search deferral for clients that cannot execute it ([#2696](https://github.com/headroomlabs-ai/headroom/issues/2696)) ([54ea28d](https://github.com/headroomlabs-ai/headroom/commit/54ea28d9839a0dcfa4dd0cf4210a4421f03beeff))
* **opencode:** ship the transport hook-shim so wheel installs route Node child traffic ([702dbc5](https://github.com/headroomlabs-ai/headroom/commit/702dbc5902ff184a7c20178958a811beb9c78fa3))
* **providers/anthropic:** don't crash token estimation on null tool_calls ([#2472](https://github.com/headroomlabs-ai/headroom/issues/2472)) ([08466f3](https://github.com/headroomlabs-ai/headroom/commit/08466f3cae4dbb2647dc6f249fe42c4e840600c5))
* **providers/openai:** bound tiktoken vocab loads with the guarded loader ([#2554](https://github.com/headroomlabs-ai/headroom/issues/2554)) ([0805e8e](https://github.com/headroomlabs-ai/headroom/commit/0805e8e410543d75c7ddd3b83dde5eda3bc13144))
* **proxy/anthropic:** inject headroom_retrieve whenever a CCR marker is present, not only for new markers ([#2848](https://github.com/headroomlabs-ai/headroom/issues/2848)) ([3808f60](https://github.com/headroomlabs-ai/headroom/commit/3808f60ca61e84faf3ea8f8e003a6e6c8e9af4da))
* **proxy/anthropic:** None-guard usage token counts on the direct buffered path ([#2434](https://github.com/headroomlabs-ai/headroom/issues/2434)) ([2b5ee7c](https://github.com/headroomlabs-ai/headroom/commit/2b5ee7cde809ca37f6998d9679b1eb2133ab50ca))
* **proxy/anthropic:** run tool-search history repair after turn hooks ([c6f9948](https://github.com/headroomlabs-ai/headroom/commit/c6f99482e1bea024db6014a70c8e6da419543957))
* **proxy/batch:** don't crash an OpenAI batch on a valid-JSON non-object line ([#2316](https://github.com/headroomlabs-ai/headroom/issues/2316)) ([1f2c681](https://github.com/headroomlabs-ai/headroom/commit/1f2c681c0b48150a569277d3ebd5e95709dc7c39))
* **proxy/bedrock:** report uncached input tokens from backend usage, not the live-zone count ([#2318](https://github.com/headroomlabs-ai/headroom/issues/2318)) ([c19e412](https://github.com/headroomlabs-ai/headroom/commit/c19e412b3356d80dece001887d4ff48b6fd5150b))
* **proxy/gemini:** keep streaming-parity baseline so eligible_pct can't exceed 100 ([#2824](https://github.com/headroomlabs-ai/headroom/issues/2824)) ([b97c7c6](https://github.com/headroomlabs-ai/headroom/commit/b97c7c6e99eac84df49c7a7e5f21dedb298716fe))
* **proxy/metrics:** cap client-supplied model label cardinality ([#2480](https://github.com/headroomlabs-ai/headroom/issues/2480)) ([e24a7e6](https://github.com/headroomlabs-ai/headroom/commit/e24a7e66b95fa908c4ea6fd079809ece7692e6b2))
* **proxy/metrics:** escape label values in the Prometheus export ([#2463](https://github.com/headroomlabs-ai/headroom/issues/2463)) ([6a53861](https://github.com/headroomlabs-ai/headroom/commit/6a53861063c3839e698bbec7194517bdfd851c38))
* **proxy/openai:** don't crash the Responses memory tool loops on null arguments ([#2273](https://github.com/headroomlabs-ai/headroom/issues/2273)) ([a30db2c](https://github.com/headroomlabs-ai/headroom/commit/a30db2cae49b4ef03ebbd404ec1fc6c4f5f2404d))
* **proxy/openai:** feed Codex WS traffic into the traffic learner ([#2334](https://github.com/headroomlabs-ai/headroom/issues/2334)) ([f669149](https://github.com/headroomlabs-ai/headroom/commit/f6691497692869b7067438597421ff12aace6bf4))
* **proxy/openai:** run response hooks on Responses, and bill their re-drives ([#2872](https://github.com/headroomlabs-ai/headroom/issues/2872)) ([675d13f](https://github.com/headroomlabs-ai/headroom/commit/675d13f08d42455c8fa17bda878c1a11b905cee4))
* **proxy:** allow settings routes for trusted gateway/dashboard clients ([#2491](https://github.com/headroomlabs-ai/headroom/issues/2491)) ([a5b0a8f](https://github.com/headroomlabs-ai/headroom/commit/a5b0a8f4cc54d68afcf371a422b3a4a9635b7e7f))
* **proxy:** cache litellm model resolution to stop repeated Provider List spam ([99f07e7](https://github.com/headroomlabs-ai/headroom/commit/99f07e7bbdded9dadc70e35ee6ab025279d1aa22))
* **proxy:** cancel periodic TOIN task on shutdown ([739fdef](https://github.com/headroomlabs-ai/headroom/commit/739fdef423fa8cbc82537481c875d4570b0ecad4))
* **proxy:** close the upstream stream when a streaming body is never consumed ([0951663](https://github.com/headroomlabs-ai/headroom/commit/09516635621caccf7e3db4f537eb49ea49b8a453))
* **proxy:** compress cache-mode cold starts and tag prefix-mismatch passthrough ([#2365](https://github.com/headroomlabs-ai/headroom/issues/2365)) ([aaeba0a](https://github.com/headroomlabs-ai/headroom/commit/aaeba0a319f12b98cad3bfcf1cf991b694b946bf))
* **proxy:** emit request log timestamps in UTC ([620028f](https://github.com/headroomlabs-ai/headroom/commit/620028fa18843622d3e454bd40fb91a93e607dbf))
* **proxy:** enable tool search by default and repair poisoned transcripts ([#2807](https://github.com/headroomlabs-ai/headroom/issues/2807)) ([0237cbf](https://github.com/headroomlabs-ai/headroom/commit/0237cbffbbc456ad8a7398005602d76881862d99))
* **proxy:** gate mid-turn message coalescing to Claude Code clients ([#1643](https://github.com/headroomlabs-ai/headroom/issues/1643)) ([a4bd2e6](https://github.com/headroomlabs-ai/headroom/commit/a4bd2e62a5bb73f15b3b12e979c69e2b555bee10))
* **proxy:** give each Codex /v1/responses WS turn a unique request_id ([#2164](https://github.com/headroomlabs-ai/headroom/issues/2164)) ([d02df10](https://github.com/headroomlabs-ai/headroom/commit/d02df1075894b414d60626aca2bbcadd7a3577a0))
* **proxy:** graceful shutdown and reliable Ctrl+C exit ([#621](https://github.com/headroomlabs-ai/headroom/issues/621)) ([17cdb18](https://github.com/headroomlabs-ai/headroom/commit/17cdb185bc79d8cfec104e781a7e555af3ef11e1))
* **proxy:** guard telemetry and TOIN endpoints ([cde1513](https://github.com/headroomlabs-ai/headroom/commit/cde1513c91b6c6c240869bc5660f4b8966197bbc))
* **proxy:** include tool_search_deferral savings in the savings ledger ([12149f7](https://github.com/headroomlabs-ai/headroom/commit/12149f74466c08b69be8d5fe751425be63c2fda4))
* **proxy:** pass through cross-region prefixed Bedrock model IDs directly ([#2330](https://github.com/headroomlabs-ai/headroom/issues/2330)) ([64cb46e](https://github.com/headroomlabs-ai/headroom/commit/64cb46e24bf7b223ea71b14b6f5e86e78fa7ac45))
* **proxy:** port session-sticky beta headers to the Rust proxy ([#2381](https://github.com/headroomlabs-ai/headroom/issues/2381)) ([f6398a6](https://github.com/headroomlabs-ai/headroom/commit/f6398a64768a095b722a5fb0b2445c7953dee1c6))
* **proxy:** preserve merged session and quarantine contracts ([#2943](https://github.com/headroomlabs-ai/headroom/issues/2943)) ([039cd24](https://github.com/headroomlabs-ai/headroom/commit/039cd2431aaec7d59fefaf7e97aeda1fd7ab3afa))
* **proxy:** preserve signed Anthropic thinking blocks on outbound re-serialize ([#2254](https://github.com/headroomlabs-ai/headroom/issues/2254)) ([dc163bc](https://github.com/headroomlabs-ai/headroom/commit/dc163bcd1cba4cd8898f23286eb1365fcf6e0356))
* **proxy:** stop discarding compressed Codex WS later-frame payloads ([#2823](https://github.com/headroomlabs-ai/headroom/issues/2823)) ([4ec416d](https://github.com/headroomlabs-ai/headroom/commit/4ec416df8899036544e679f561f1cf921f3da0dd))
* **proxy:** time-cap the compression timeout-debt quarantine ([#2360](https://github.com/headroomlabs-ai/headroom/issues/2360)) ([#2412](https://github.com/headroomlabs-ai/headroom/issues/2412)) ([c5a08d2](https://github.com/headroomlabs-ai/headroom/commit/c5a08d22e05a7dd2b929f3cca76ee3fb42f122db))
* **proxy:** unwrap Hermes tool_call bridge in tool name map ([#2717](https://github.com/headroomlabs-ai/headroom/issues/2717)) ([a97b824](https://github.com/headroomlabs-ai/headroom/commit/a97b82413bdc86655c064417ed4628ff4d9d7c9d))
* publish headroom-opencode in release workflow ([#2372](https://github.com/headroomlabs-ai/headroom/issues/2372)) ([7859154](https://github.com/headroomlabs-ai/headroom/commit/78591545ceb8303fdf9b93cd5ff02b626df97d2b))
* **settings:** accept documented HEADROOM_* env names as settings keys ([#2833](https://github.com/headroomlabs-ai/headroom/issues/2833)) ([de9e052](https://github.com/headroomlabs-ai/headroom/commit/de9e0523dad47b700062464adecd60f82547f332))
* **subscription:** dedup transcript usage by message id ([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340) token inflation) ([#2408](https://github.com/headroomlabs-ai/headroom/issues/2408)) ([74275b7](https://github.com/headroomlabs-ai/headroom/commit/74275b7c3e2b39be5198f9efa35057a5e026e665))
* **toin:** bound private query and pattern retention ([8cd1380](https://github.com/headroomlabs-ai/headroom/commit/8cd138039edbfc295080ec474325d527fb3aedf3))
* **tokenizer:** coerce non-string tool_call fields before counting ([#2801](https://github.com/headroomlabs-ai/headroom/issues/2801)) ([b6f9877](https://github.com/headroomlabs-ai/headroom/commit/b6f9877c78b3fa3b1d705426bd27d74be77f4fa0))
* **tokenizer:** price CJK in the Rust fixed-ratio estimator (Python parity) ([#2260](https://github.com/headroomlabs-ai/headroom/issues/2260)) ([6840153](https://github.com/headroomlabs-ai/headroom/commit/6840153473caa0d61e982215e16a8cf54b0b6cc7))
* **transforms/adaptive-sizer:** honor max_k on small-input fast path ([#2319](https://github.com/headroomlabs-ai/headroom/issues/2319)) ([8a90523](https://github.com/headroomlabs-ai/headroom/commit/8a905232091d993fac9e19a59bc449f201d4cdf3))
* **transforms/smart_crusher:** don't crash on a tool call with a null function ([#2232](https://github.com/headroomlabs-ai/headroom/issues/2232)) ([3bb02f8](https://github.com/headroomlabs-ai/headroom/commit/3bb02f8f75f12cf8258a5b1c2a7fbdc190f9d074))
* Vertex model pricing shows $0.00 for versioned model names and vertex:anthropic provider ([#2517](https://github.com/headroomlabs-ai/headroom/issues/2517)) ([eb5b5e4](https://github.com/headroomlabs-ai/headroom/commit/eb5b5e41988f5c27d29ae8ae3e5fe74e56493b8c))
* **wrap/claude:** keep --1m effective when an explicit --model is passed through ([c093bf1](https://github.com/headroomlabs-ai/headroom/commit/c093bf11eb5f356f71367ebb7b56ae3c2b434a12))
* **wrap/opencode:** verify the opencode binary before mutating config ([ae38486](https://github.com/headroomlabs-ai/headroom/commit/ae384862a4950cec057103e9daf75e74107640df))
* **wrap/serena:** install Serena from the serena-agent PyPI wheel, not the git source ([d7b25ae](https://github.com/headroomlabs-ai/headroom/commit/d7b25ae3bb3364cde4931509ecb65e32085e5b09))
* **wrap:** honor Copilot OAuth wire-api override and model default ([#2387](https://github.com/headroomlabs-ai/headroom/issues/2387)) ([1db6d88](https://github.com/headroomlabs-ai/headroom/commit/1db6d88ab4ea25654b8277358902b7df700db6b4))
* **wrap:** serialize shared proxy startup ([#2946](https://github.com/headroomlabs-ai/headroom/issues/2946)) ([e540d64](https://github.com/headroomlabs-ai/headroom/commit/e540d64febf27f2e7997d3a1a1d89478cc1ef658))
* **wrap:** stop the launch cwd from shadowing the installed package in the proxy subprocess ([#2843](https://github.com/headroomlabs-ai/headroom/issues/2843)) ([c49be26](https://github.com/headroomlabs-ai/headroom/commit/c49be269a18446779cd8a048caaa7f0ba3a3b48b))
### Performance Improvements
* cut hot-path latency 27% (token-count memo, startup preloads, JSON scan memo) ([#2838](https://github.com/headroomlabs-ai/headroom/issues/2838)) ([53af90d](https://github.com/headroomlabs-ai/headroom/commit/53af90d68c723f644a5a41dd273a606117109866))
* **proxy:** bound upstream calls and hot-path costs ([#2852](https://github.com/headroomlabs-ai/headroom/issues/2852)) ([f624d3a](https://github.com/headroomlabs-ai/headroom/commit/f624d3a00ac271db7947443ddeb0c8bc2e93d3eb))
* **subscription:** skip transcripts older than the window in compute_window_tokens ([#2861](https://github.com/headroomlabs-ai/headroom/issues/2861)) ([91d6bf3](https://github.com/headroomlabs-ai/headroom/commit/91d6bf33cde777b541375fb182d4479fdd78f81b))
### Dependencies
* bump brace-expansion from 5.0.7 to 5.0.9 in /docs ([#2751](https://github.com/headroomlabs-ai/headroom/issues/2751)) ([56ee57b](https://github.com/headroomlabs-ai/headroom/commit/56ee57be98bf109f0a46de522724ef169a4bc51c))
* bump bytesize from 1.3.3 to 2.4.2 ([#2286](https://github.com/headroomlabs-ai/headroom/issues/2286)) ([6448545](https://github.com/headroomlabs-ai/headroom/commit/6448545a7f5a1dee88bce6f0830bdbfd1c99c617))
* bump hf-hub from 0.4.3 to 0.5.0 ([#2285](https://github.com/headroomlabs-ai/headroom/issues/2285)) ([4925bf6](https://github.com/headroomlabs-ai/headroom/commit/4925bf6a829735977bab5000b469c3edb19c75b1))
* bump next from 16.2.10 to 16.3.0 in /docs ([#2750](https://github.com/headroomlabs-ai/headroom/issues/2750)) ([0fd0b99](https://github.com/headroomlabs-ai/headroom/commit/0fd0b996a4b58a166491b145f4d3885c21b27cc0))
* bump postcss from 8.5.19 to 8.5.25 in /plugins/openclaw ([#2749](https://github.com/headroomlabs-ai/headroom/issues/2749)) ([cd60ee9](https://github.com/headroomlabs-ai/headroom/commit/cd60ee9ae886b32ba5da3203e35bb6b088031fd3))
* bump postcss from 8.5.19 to 8.5.25 in /plugins/opencode ([#2748](https://github.com/headroomlabs-ai/headroom/issues/2748)) ([ff4e016](https://github.com/headroomlabs-ai/headroom/commit/ff4e0167bbccbd4ae51bf23ddec144e61c94cd68))
* bump postcss from 8.5.19 to 8.5.25 in /sdk/typescript ([#2747](https://github.com/headroomlabs-ai/headroom/issues/2747)) ([267c2bd](https://github.com/headroomlabs-ai/headroom/commit/267c2bdcb56e132b2dd9c065dab3498dbf730ca3))
* bump postcss from 8.5.19 to 8.5.26 in /docs ([#2881](https://github.com/headroomlabs-ai/headroom/issues/2881)) ([e6e5826](https://github.com/headroomlabs-ai/headroom/commit/e6e5826423a0a700a8c544ce2c8cbcdef694160e))
* bump ruff from 0.15.17 to 0.15.22 in the pip-minor-patch group ([#2501](https://github.com/headroomlabs-ai/headroom/issues/2501)) ([ecf130d](https://github.com/headroomlabs-ai/headroom/commit/ecf130d3ac6fb864098cb93fafd2621ae3ac7e12))
* bump rusqlite from 0.32.1 to 0.40.1 ([#2287](https://github.com/headroomlabs-ai/headroom/issues/2287)) ([522faa1](https://github.com/headroomlabs-ai/headroom/commit/522faa1a59aa94e4adfd4a4afe0202d1126e187d))
* bump the cargo-minor-patch group across 1 directory with 22 updates ([#2916](https://github.com/headroomlabs-ai/headroom/issues/2916)) ([148d860](https://github.com/headroomlabs-ai/headroom/commit/148d8605e2087f3c8d6a3fa4b8d248ad2da5858f))
## [0.34.0](https://github.com/headroomlabs-ai/headroom/compare/v0.33.0...v0.34.0) (2026-08-05)
### Features
* **claude:** support Claude Code in VS Code ([#2752](https://github.com/headroomlabs-ai/headroom/issues/2752)) ([13a310a](https://github.com/headroomlabs-ai/headroom/commit/13a310a00de8e967ebe09502c6b715ef577c5926))
* **code:** add PHP support to CodeAwareCompressor ([#2423](https://github.com/headroomlabs-ai/headroom/issues/2423)) ([6d5516d](https://github.com/headroomlabs-ai/headroom/commit/6d5516dcb878b6ffd139a1c7b3d480a1c8c1beb9))
* **compress:** accept config.frozen_message_count on /v1/compress ([#2718](https://github.com/headroomlabs-ai/headroom/issues/2718)) ([2797099](https://github.com/headroomlabs-ai/headroom/commit/2797099becbd078e55b8a73cf904d2e3cb0d6889))
* **compress:** reach the lossless provider seam on the general path and default /v1/compress to marker-free output ([#2691](https://github.com/headroomlabs-ai/headroom/issues/2691)) ([f2c48e2](https://github.com/headroomlabs-ai/headroom/commit/f2c48e26c684a31e2802de9f49ce2075ef9cbf4b))
* **copilot:** proxy VS Code models transparently ([#2687](https://github.com/headroomlabs-ai/headroom/issues/2687)) ([007446c](https://github.com/headroomlabs-ai/headroom/commit/007446c73a26efa729bf6d6903c828adef730089))
### Bug Fixes
* **ccr:** stop persisting retrieval markers as original content ([#2694](https://github.com/headroomlabs-ai/headroom/issues/2694)) ([#2703](https://github.com/headroomlabs-ai/headroom/issues/2703)) ([3e348f3](https://github.com/headroomlabs-ai/headroom/commit/3e348f327f05921204329b72a57d3113cf5101c4))
* **ci:** restrict Codecov shard uploads ([#2745](https://github.com/headroomlabs-ai/headroom/issues/2745)) ([3f2ca99](https://github.com/headroomlabs-ai/headroom/commit/3f2ca99fe16668e3d50b8e1706182ec7b226c352))
* **compression:** honor qualified CCR names across integrations ([#2698](https://github.com/headroomlabs-ai/headroom/issues/2698)) ([dcb674b](https://github.com/headroomlabs-ai/headroom/commit/dcb674b5e4e0d29d52672118ba3cf5062b16d280))
* **compress:** resolve the /v1/compress tokenizer per model, and document the real contract ([#2743](https://github.com/headroomlabs-ai/headroom/issues/2743)) ([6422a80](https://github.com/headroomlabs-ai/headroom/commit/6422a80a58010da805d4001e83265300aa716d8a))
* **cost:** send litellm the total prompt so --budget stops seeing $0 ([#2757](https://github.com/headroomlabs-ai/headroom/issues/2757)) ([a033ac4](https://github.com/headroomlabs-ai/headroom/commit/a033ac4176b09c716905aa0f45ae317e954f0eb9))
* **deps:** bump aiohttp and cryptography to clear the CVEs blocking 0.34.0 ([#2753](https://github.com/headroomlabs-ai/headroom/issues/2753)) ([0221e7f](https://github.com/headroomlabs-ai/headroom/commit/0221e7f240cf470628650d749dc0ab5f3f0135f3))
* **kompress:** let orgs run Kompress on their own inference stack ([#2736](https://github.com/headroomlabs-ai/headroom/issues/2736)) ([3d23d76](https://github.com/headroomlabs-ai/headroom/commit/3d23d76248d2052b846a84b70be87c8c95bad9ac))
* **kompress:** load merged.pt for the v2 checkpoint instead of the unmerged PEFT safetensors ([#2716](https://github.com/headroomlabs-ai/headroom/issues/2716)) ([46da91b](https://github.com/headroomlabs-ai/headroom/commit/46da91b2f1370b6b4910ae8a4ad0613929803887))
* **kompress:** reject artifacts that fail at run, and prefetch model files at startup ([#2740](https://github.com/headroomlabs-ai/headroom/issues/2740)) ([224578e](https://github.com/headroomlabs-ai/headroom/commit/224578e80b4abbe1e16f1952efc24af5fdee106a))
* **learn:** filter ambient user-role scaffolding ([#2275](https://github.com/headroomlabs-ai/headroom/issues/2275)) ([3eb0122](https://github.com/headroomlabs-ai/headroom/commit/3eb01220683d65660544c07631b1efb4781e1d53))
* **learn:** run project discovery off the event loop ([#2731](https://github.com/headroomlabs-ai/headroom/issues/2731)) ([a70e5ff](https://github.com/headroomlabs-ai/headroom/commit/a70e5ff78dc9486e63a6563f122d392469ceef38))
* normalize /p/&lt;project&gt; prefix on WebSocket upgrades so the Responses WS route is not rejected with 403 ([#2379](https://github.com/headroomlabs-ai/headroom/issues/2379)) ([789a4f3](https://github.com/headroomlabs-ai/headroom/commit/789a4f3060aa33a5bae82680968c3e367fa2db83))
* **providers:** give every model exactly one tokenizer ([#2761](https://github.com/headroomlabs-ai/headroom/issues/2761)) ([cd92ed5](https://github.com/headroomlabs-ai/headroom/commit/cd92ed52ff80ee7932600306349fd5d6601b5404))
* **providers:** stop a shorter model family shadowing a longer one ([#2762](https://github.com/headroomlabs-ai/headroom/issues/2762)) ([0cb72f4](https://github.com/headroomlabs-ai/headroom/commit/0cb72f45b23bdf7129822b16dd1d4cb7d6e0b062))
* **providers:** stop pricing modern content blocks at zero ([#2760](https://github.com/headroomlabs-ai/headroom/issues/2760)) ([06add9e](https://github.com/headroomlabs-ai/headroom/commit/06add9e9d833783c1144316c0cb3cb142377d897))
* **proxy/cost:** mark estimated-basis budget records and add an enforcement policy ([#2713](https://github.com/headroomlabs-ai/headroom/issues/2713)) ([#2725](https://github.com/headroomlabs-ai/headroom/issues/2725)) ([01df245](https://github.com/headroomlabs-ai/headroom/commit/01df2452529a86c689cf226fecd5918cc5d19676))
* **proxy/debug:** reconcile Kompress warmup state in /debug/warmup ([#2711](https://github.com/headroomlabs-ai/headroom/issues/2711)) ([3a27c4d](https://github.com/headroomlabs-ai/headroom/commit/3a27c4dacb08a006ca5aa71e8e7728b230c7283f))
* **proxy/openai:** run tool-description compaction on chat-completions ([#2741](https://github.com/headroomlabs-ai/headroom/issues/2741)) ([f9db5b5](https://github.com/headroomlabs-ai/headroom/commit/f9db5b506030a0e8557af8a350f3806464f8ff15))
* **proxy:** route Codex Live voice through a dedicated /v1/live transport ([#2709](https://github.com/headroomlabs-ai/headroom/issues/2709)) ([232fb49](https://github.com/headroomlabs-ai/headroom/commit/232fb49c733122652528edcf3c500f365df265c4))
* **proxy:** skip OpenAI tool_search deferral for Codex client ([#2729](https://github.com/headroomlabs-ai/headroom/issues/2729)) ([56b3e4c](https://github.com/headroomlabs-ai/headroom/commit/56b3e4c1b1e3513c409242b30e7712514f2624d5))
* **proxy:** stop toggling headroom_retrieve in the Anthropic tools array ([#2672](https://github.com/headroomlabs-ai/headroom/issues/2672)) ([08fce29](https://github.com/headroomlabs-ai/headroom/commit/08fce29b4750a79fb2fbc3969847bb38f35e29b3))
* remove rtk and lean-ctx CLI context tools ([#2677](https://github.com/headroomlabs-ai/headroom/issues/2677)) ([e0ce4b1](https://github.com/headroomlabs-ai/headroom/commit/e0ce4b1d4817e1b352e68e8b316273d863260ba7))
* **router:** stop counting an image's base64 payload as suffix tokens ([#2778](https://github.com/headroomlabs-ai/headroom/issues/2778)) ([f03cc6d](https://github.com/headroomlabs-ai/headroom/commit/f03cc6d88b826c2752b20bdce944f9ad1e507e83))
* **savings:** surface request growth the tok_saved clamp swallows ([#2708](https://github.com/headroomlabs-ai/headroom/issues/2708)) ([184146b](https://github.com/headroomlabs-ai/headroom/commit/184146b6884b7b0e4c589c5ee414f96bf56d867f))
* **stats:** report one "Tokens Saved" headline across every harness ([#2737](https://github.com/headroomlabs-ai/headroom/issues/2737)) ([8262a4a](https://github.com/headroomlabs-ai/headroom/commit/8262a4a3217bf6125f293bacc1df9ae21f63264d))
* **telemetry:** anonymous compression stats — no prompts, no data ([#2728](https://github.com/headroomlabs-ai/headroom/issues/2728)) ([9cfb008](https://github.com/headroomlabs-ai/headroom/commit/9cfb00838a197159d94aa52bc042df1a754b7984))
* **telemetry:** stop mixing tokenizer scales in RequestOutcome, and fix the overhead framing ([#2756](https://github.com/headroomlabs-ai/headroom/issues/2756)) ([04e1517](https://github.com/headroomlabs-ai/headroom/commit/04e1517ede0a17ffa950a9531f210e31d236c660))
* **tokenizers:** count HuggingFace chat templates, and resolve gpt-5 / gateway-wrapped names ([#2758](https://github.com/headroomlabs-ai/headroom/issues/2758)) ([0ed306b](https://github.com/headroomlabs-ai/headroom/commit/0ed306b22bf61bfaa421991aea0bd562fc97d910))
* **tokenizers:** resolve gpt-5 and mixed-case model names to the right encoding ([#2776](https://github.com/headroomlabs-ai/headroom/issues/2776)) ([fc4680b](https://github.com/headroomlabs-ai/headroom/commit/fc4680b37af1d522fdbeba8e5d3228769dc49ba4))
* **transforms:** stop ContentRouter recompressing headroom_retrieve results ([#2654](https://github.com/headroomlabs-ai/headroom/issues/2654)) ([677e097](https://github.com/headroomlabs-ai/headroom/commit/677e09735a41f6c37dedc842ab3c214b5bddeafc))
* **wrap/serena:** stop creating serena_config.yml, unbricking Serena on fresh installs ([#2676](https://github.com/headroomlabs-ai/headroom/issues/2676)) ([759209c](https://github.com/headroomlabs-ai/headroom/commit/759209cff3daa72dd9d47e57568e731d10573d63))
### Code Refactoring
* **pricing:** make LiteLLM the source of truth, not the hardcoded table ([#2779](https://github.com/headroomlabs-ai/headroom/issues/2779)) ([0e1d6bf](https://github.com/headroomlabs-ai/headroom/commit/0e1d6bfa797d865834cc247989115a11949ce3f5))
* remove the dead headroom/prediction module ([#2692](https://github.com/headroomlabs-ai/headroom/issues/2692)) ([b7a79ac](https://github.com/headroomlabs-ai/headroom/commit/b7a79ac31a99ec67dc5fbe7bd15e7b96f8c040ec))
## [0.33.0](https://github.com/headroomlabs-ai/headroom/compare/v0.32.0...v0.33.0) (2026-07-29)
### Features
* **lossless:** factor shared directory prefix in the grep search fold ([#2547](https://github.com/headroomlabs-ai/headroom/issues/2547)) ([7dc9a97](https://github.com/headroomlabs-ai/headroom/commit/7dc9a978ca974a2ed264bb585b187dd11e0a04f2))
* **metrics:** record per-extension token savings ([#2371](https://github.com/headroomlabs-ai/headroom/issues/2371)) ([02eb90f](https://github.com/headroomlabs-ai/headroom/commit/02eb90f24318abdfb05438e873c8f2af7023ab91))
* **opencode:** ship the transport plugin in pip installs ([#2601](https://github.com/headroomlabs-ai/headroom/issues/2601)) ([f54f04f](https://github.com/headroomlabs-ai/headroom/commit/f54f04f5bfff9ff9f9ec83b452f580447c06254a))
* **opencode:** support Copilot subscription backend for headroom models ([#2441](https://github.com/headroomlabs-ai/headroom/issues/2441)) ([#2445](https://github.com/headroomlabs-ai/headroom/issues/2445)) ([9089e7f](https://github.com/headroomlabs-ai/headroom/commit/9089e7f7d394b5a474cc99503b0197c0172f4c9c))
* **proxy/hooks:** run fold-only (stream-safe) turn hooks on streaming OpenAI chat ([#2549](https://github.com/headroomlabs-ai/headroom/issues/2549)) ([a6d4921](https://github.com/headroomlabs-ai/headroom/commit/a6d4921e82c1e9fe1a5ca8b90ffd16aa84a698d4))
* **proxy/savings:** aggregate tool-schema savings into Metrics + all reporting sinks ([#2546](https://github.com/headroomlabs-ai/headroom/issues/2546)) ([9f1ffef](https://github.com/headroomlabs-ai/headroom/commit/9f1ffefe83845a3af0ecd8013daa732c3cd56b7c))
* **proxy:** label GitHub Copilot traffic as "copilot" in the outcome… ([#2377](https://github.com/headroomlabs-ai/headroom/issues/2377)) ([d7a8cdb](https://github.com/headroomlabs-ai/headroom/commit/d7a8cdbee1c500be35b87c9da8395087a37ff8b9))
* **proxy:** make /v1/compress usable as a gateway/Kong sidecar ([#2458](https://github.com/headroomlabs-ai/headroom/issues/2458)) ([1329ed7](https://github.com/headroomlabs-ai/headroom/commit/1329ed7f1a8d7a018042ecbe41804b0be971792e))
* **proxy:** model-aware cold-prefix hook — reasoning compaction (Kimi/GLM) + cold recompaction (CC) ([#2555](https://github.com/headroomlabs-ai/headroom/issues/2555)) ([cb8f4b6](https://github.com/headroomlabs-ai/headroom/commit/cb8f4b64367f8b034315db33e451bdbe87af61f2))
* **proxy:** route selected external compressors through the content router ([#2388](https://github.com/headroomlabs-ai/headroom/issues/2388)) ([e3c7964](https://github.com/headroomlabs-ai/headroom/commit/e3c7964038116a8df4675840896712e1aa967c45))
* **proxy:** select built-in compressors via --compressor + registry inventory ([#2373](https://github.com/headroomlabs-ai/headroom/issues/2373)) ([56c7d4a](https://github.com/headroomlabs-ai/headroom/commit/56c7d4a59e67655cd24040ecf729382c81cdec23))
* **rust:** add structured prose offload plumbing ([#334](https://github.com/headroomlabs-ai/headroom/issues/334)) ([#2378](https://github.com/headroomlabs-ai/headroom/issues/2378)) ([9e07785](https://github.com/headroomlabs-ai/headroom/commit/9e0778553fc505edb2c5bc949b7277f9ffdf3bda))
* **rust:** port CodeCompressor AST compressor to Rust (parity-only) ([#1154](https://github.com/headroomlabs-ai/headroom/issues/1154)) ([e530de5](https://github.com/headroomlabs-ai/headroom/commit/e530de5ad22100bcfaa12a463961dcb08d9671c8))
* **rust:** port Kompress ML prose compressor to Rust (parity-only) ([#1153](https://github.com/headroomlabs-ai/headroom/issues/1153)) ([83e27e5](https://github.com/headroomlabs-ai/headroom/commit/83e27e50360753cf472acb99f1de992574fa80ae))
* **telemetry:** record provider cache read/write/uncached tokens per request ([#2450](https://github.com/headroomlabs-ai/headroom/issues/2450)) ([bec4cce](https://github.com/headroomlabs-ai/headroom/commit/bec4cce8a9f5623e63dba0a847719a652b47d5dc))
* **transforms:** add compressed signal + dispatch code_aware/html/diff via registry ([#2400](https://github.com/headroomlabs-ai/headroom/issues/2400)) ([7ebda67](https://github.com/headroomlabs-ai/headroom/commit/7ebda67ef65fe82803c7fb729c509a1451165f26))
* **transforms:** add pluggable compressor registry + headroom.compressor entry point ([#2370](https://github.com/headroomlabs-ai/headroom/issues/2370)) ([a02073e](https://github.com/headroomlabs-ai/headroom/commit/a02073e3327365a0220ba04eeb10039f12d61684))
* **transforms:** dispatch kompress/text via the compressor registry + forward question ([#2411](https://github.com/headroomlabs-ai/headroom/issues/2411)) ([446ec26](https://github.com/headroomlabs-ai/headroom/commit/446ec26003c8f661cec175a69e0ab8be0ae9cdea))
* **transforms:** dispatch smart_crusher via the compressor registry (defer kompress/text ML boundary) ([#2404](https://github.com/headroomlabs-ai/headroom/issues/2404)) ([7c7bf43](https://github.com/headroomlabs-ai/headroom/commit/7c7bf430576541d0fffdb8fc727b76f3dd038f55))
* **transforms:** make built-in compressors real Compressor implementations (adapters) ([#2391](https://github.com/headroomlabs-ai/headroom/issues/2391)) ([981616c](https://github.com/headroomlabs-ai/headroom/commit/981616c60ef04c32b3eb5b51c4f0f4a7ef297ef1))
* **wrap:** boost Serena — symbol-first guidance, wrap-time pre-index, repo-language scoping ([#2425](https://github.com/headroomlabs-ai/headroom/issues/2425)) ([fd0e1a8](https://github.com/headroomlabs-ai/headroom/commit/fd0e1a8afeb60748f65fef8b9197ec95e23b335a))
* **wrap:** default code-memory to Serena (dashboard browser off) behind unified --code-memory ([#2413](https://github.com/headroomlabs-ai/headroom/issues/2413)) ([6e4425a](https://github.com/headroomlabs-ai/headroom/commit/6e4425a6bdb2bfc49e1633a24b9c9e96e705e1ff))
* **wrap:** reduce-at-source — SAFE quiet-CLI env defaults for the launched agent ([#2548](https://github.com/headroomlabs-ai/headroom/issues/2548)) ([c990cfb](https://github.com/headroomlabs-ai/headroom/commit/c990cfb8037e8f355c82eb1cef87f5c4297b612d))
### Bug Fixes
* **backends/litellm:** guard None completion_tokens in usage mapping ([#2322](https://github.com/headroomlabs-ai/headroom/issues/2322)) ([44a174f](https://github.com/headroomlabs-ai/headroom/commit/44a174fef4d514eceed20a767dc87d00cfde0eaa))
* **backends:** don't crash the OpenAI-&gt;Anthropic converter on empty choices ([#2484](https://github.com/headroomlabs-ai/headroom/issues/2484)) ([43a7b57](https://github.com/headroomlabs-ai/headroom/commit/43a7b578a1377ad34d8a78ba3bcef1c276db0b4d))
* **cache:** preserve cache_control ttl when re-anchoring a breakpoint ([#2651](https://github.com/headroomlabs-ai/headroom/issues/2651)) ([e0d2cd0](https://github.com/headroomlabs-ai/headroom/commit/e0d2cd0c5a1c3ee813ac225252c9fd8db7c77c12))
* **cache:** preserve client cache_control ttl when consolidating breakpoints ([#2382](https://github.com/headroomlabs-ai/headroom/issues/2382)) ([8906d3a](https://github.com/headroomlabs-ai/headroom/commit/8906d3a6761c097bbc9d92a0b41f8c982afc633b))
* **ccr:** guard empty/malformed OpenAI choices in _extract_assistant_message ([#2389](https://github.com/headroomlabs-ai/headroom/issues/2389)) ([89319fb](https://github.com/headroomlabs-ai/headroom/commit/89319fbcaddb4be2ea11e87858ed3bd0fcf9dca5))
* **ccr:** sliding idle-window TTL with max-lifetime ceiling in the Rust core backends ([#2604](https://github.com/headroomlabs-ai/headroom/issues/2604)) ([#2631](https://github.com/headroomlabs-ai/headroom/issues/2631)) ([e825588](https://github.com/headroomlabs-ai/headroom/commit/e825588bfbc59fa9e86085e23b4a078e9a0038ba))
* **ci:** align Ruff tooling versions ([#2406](https://github.com/headroomlabs-ai/headroom/issues/2406)) ([2bb14d1](https://github.com/headroomlabs-ai/headroom/commit/2bb14d1ab24617971a657b71ead567479021119d))
* **cli:** warn when Headroom proxy URL leaks into the shell after unwrap claude ([#2238](https://github.com/headroomlabs-ai/headroom/issues/2238)) ([#2571](https://github.com/headroomlabs-ai/headroom/issues/2571)) ([904bc67](https://github.com/headroomlabs-ai/headroom/commit/904bc675b35072dc61191963cbe485fa692927d1))
* **codex:** detect keyring-backed ChatGPT auth ([#2478](https://github.com/headroomlabs-ai/headroom/issues/2478)) ([46293f4](https://github.com/headroomlabs-ai/headroom/commit/46293f4daf4d217ab6f8a83f7c571571b79bae0c))
* **compression:** report source-line span in CCR compression marker ([#2597](https://github.com/headroomlabs-ai/headroom/issues/2597)) ([18e1c3c](https://github.com/headroomlabs-ai/headroom/commit/18e1c3c9badc5169466b7f76ae08e0639f4ba104))
* **copilot:** derive GHE credential host from API URL ([#800](https://github.com/headroomlabs-ai/headroom/issues/800)) ([#2511](https://github.com/headroomlabs-ai/headroom/issues/2511)) ([4a8157f](https://github.com/headroomlabs-ai/headroom/commit/4a8157fa0a3f1d07699f1071ceb653f8902f10a4))
* **copilot:** normalize subscription API routing ([#2441](https://github.com/headroomlabs-ai/headroom/issues/2441)) ([#2455](https://github.com/headroomlabs-ai/headroom/issues/2455)) ([2eca5ee](https://github.com/headroomlabs-ai/headroom/commit/2eca5ee1140c9ce0a5fee05e604d3198f7f86026))
* **copilot:** preserve /v1 for the Anthropic /v1/messages endpoint ([#2409](https://github.com/headroomlabs-ai/headroom/issues/2409)) ([#2414](https://github.com/headroomlabs-ai/headroom/issues/2414)) ([c400f90](https://github.com/headroomlabs-ai/headroom/commit/c400f9081052f633e4e64ad70b95a0230dc6fb3d))
* **deps:** bump mcp to 1.28.1 to clear 3 high-severity CVEs ([#2348](https://github.com/headroomlabs-ai/headroom/issues/2348)) ([a90be94](https://github.com/headroomlabs-ai/headroom/commit/a90be94e32c393332d37db4fb439e0c776b89f27))
* **grok:** preserve business-seat auth while routing only inference ([#2514](https://github.com/headroomlabs-ai/headroom/issues/2514)) ([e4076bb](https://github.com/headroomlabs-ai/headroom/commit/e4076bbe99d500982b51444fe37f8f467cd6abe2))
* **image:** reuse image models instead of rebuilding them per request ([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513)) ([#2536](https://github.com/headroomlabs-ai/headroom/issues/2536)) ([2a63ec7](https://github.com/headroomlabs-ai/headroom/commit/2a63ec70b65605dfcff1b0afc292ab0298459f20))
* **install:** carry upstream-routing env overrides into supervised deployments ([#2429](https://github.com/headroomlabs-ai/headroom/issues/2429)) ([170b04a](https://github.com/headroomlabs-ai/headroom/commit/170b04a74d5361cdfac4a6e265f5ea0dfecbd841))
* **install:** default to cache mode, matching `headroom proxy` ([#1893](https://github.com/headroomlabs-ai/headroom/issues/1893) follow-up) ([#2563](https://github.com/headroomlabs-ai/headroom/issues/2563)) ([b121223](https://github.com/headroomlabs-ai/headroom/commit/b121223ec97e95c5a7a4c2c5e06a4655c7328e88))
* **install:** migrate deployments off the retired chopratejas image repo ([#2427](https://github.com/headroomlabs-ai/headroom/issues/2427)) ([17ff13c](https://github.com/headroomlabs-ai/headroom/commit/17ff13ccbe274e831d5d9327740cd6d506ea8c1c))
* **install:** use CREATE_NO_WINDOW instead of DETACHED_PROCESS on Windows ([#2527](https://github.com/headroomlabs-ai/headroom/issues/2527)) ([045f3df](https://github.com/headroomlabs-ai/headroom/commit/045f3dfe6fd9f4e39e4cdd8c0c529a815d925c7e))
* **kompress:** raise the default execution-slot wait ([#2456](https://github.com/headroomlabs-ai/headroom/issues/2456)) ([5bd2266](https://github.com/headroomlabs-ai/headroom/commit/5bd2266f16bb351a7a7334e1c29c598d28187b1d))
* **learn:** detect the active OpenCode database ([#2587](https://github.com/headroomlabs-ai/headroom/issues/2587)) ([f74d874](https://github.com/headroomlabs-ai/headroom/commit/f74d87477701f1f95bd4709c4727f3d3890a4e22))
* **learn:** keep traceback tail in tool-error digest preview ([#2596](https://github.com/headroomlabs-ai/headroom/issues/2596)) ([85e8699](https://github.com/headroomlabs-ai/headroom/commit/85e869945138f06471501046c5725eac119dea58))
* **learn:** treat unreadable candidate paths as absent in project decode ([#2446](https://github.com/headroomlabs-ai/headroom/issues/2446)) ([a09ba6c](https://github.com/headroomlabs-ai/headroom/commit/a09ba6c08723618dba5f282a9beac78c9406edbf))
* **mcp:** pin mcp dependency to &lt;2.0.0 to prevent server startup crash ([#2642](https://github.com/headroomlabs-ai/headroom/issues/2642)) ([b3f016b](https://github.com/headroomlabs-ai/headroom/commit/b3f016b866375cfe2ff8518055ab93844e11ec27))
* **proxy/cost:** count Gemini thinking tokens in output usage ([#2639](https://github.com/headroomlabs-ai/headroom/issues/2639)) ([22b707f](https://github.com/headroomlabs-ai/headroom/commit/22b707fd31d75914e1677290d2a8011727eb74f5))
* **proxy/cost:** record each request's savings exactly once (drop 3 double-counts) ([#2545](https://github.com/headroomlabs-ai/headroom/issues/2545)) ([0845b26](https://github.com/headroomlabs-ai/headroom/commit/0845b26ee61c507487cd8476cfabe8284f59402b))
* **proxy/cost:** warn once per model when pricing lookup fails ([#2504](https://github.com/headroomlabs-ai/headroom/issues/2504)) ([#2535](https://github.com/headroomlabs-ai/headroom/issues/2535)) ([fa47637](https://github.com/headroomlabs-ai/headroom/commit/fa4763761b5912cccde95903f4b9a681b555465b))
* **proxy/gemini:** None-guard token counts from usageMetadata ([#2347](https://github.com/headroomlabs-ai/headroom/issues/2347)) ([f64aac9](https://github.com/headroomlabs-ai/headroom/commit/f64aac9733d5e314f381644eaea62e2c28b6dc65))
* **proxy/gemini:** tolerate malformed parts on the compression path ([#2486](https://github.com/headroomlabs-ai/headroom/issues/2486)) ([07cf547](https://github.com/headroomlabs-ai/headroom/commit/07cf5476072a45bac7dd94386de126234a8049e7))
* **proxy/metrics:** move the savings-ledger append off the event loop ([#2439](https://github.com/headroomlabs-ai/headroom/issues/2439)) ([4aac068](https://github.com/headroomlabs-ai/headroom/commit/4aac068814246db3fa250c48f5c916aa2561d8c8))
* **proxy/openai:** cache under looked-up messages ([#2420](https://github.com/headroomlabs-ai/headroom/issues/2420)) ([7052d52](https://github.com/headroomlabs-ai/headroom/commit/7052d52dcbb2fd97b756c9b60a096cdfeee32c94))
* **proxy/openai:** don't record Codex WS savings without input accounting ([#2493](https://github.com/headroomlabs-ai/headroom/issues/2493)) ([2195ba7](https://github.com/headroomlabs-ai/headroom/commit/2195ba7d917649ba2ac647fdefa661cf598e3028))
* **proxy/openai:** feed chat/completions traffic into the traffic learner ([#2333](https://github.com/headroomlabs-ai/headroom/issues/2333)) ([6cdfd3f](https://github.com/headroomlabs-ai/headroom/commit/6cdfd3f64d2f64d50ed47644126df71872a21050))
* **proxy/openai:** None-guard usage token counts on the chat path ([#2431](https://github.com/headroomlabs-ai/headroom/issues/2431)) ([313c290](https://github.com/headroomlabs-ai/headroom/commit/313c290df96ca58a19ea0f79c67f5b71bb5f4d60))
* **proxy/openai:** replay incremental events in buffered Responses SSE ([#2410](https://github.com/headroomlabs-ai/headroom/issues/2410)) ([#2415](https://github.com/headroomlabs-ai/headroom/issues/2415)) ([0cbc0e8](https://github.com/headroomlabs-ai/headroom/commit/0cbc0e8e5435cd8d743ae537cdbaa70787bfc5b4))
* **proxy/output-shaping:** tolerate a non-string system block text in steering ([#2435](https://github.com/headroomlabs-ai/headroom/issues/2435)) ([3e97671](https://github.com/headroomlabs-ai/headroom/commit/3e976712e717a53ab6aea73120ae6ffacea74250))
* **proxy/perf:** count turn-hook message folds in token accounting ([#2520](https://github.com/headroomlabs-ai/headroom/issues/2520)) ([c371d5a](https://github.com/headroomlabs-ai/headroom/commit/c371d5ad602f5ab93645b2db4673ae2c5e9f0575))
* **proxy/perf:** tokenizer-consistent token accounting + surface tool-schema savings ([#2542](https://github.com/headroomlabs-ai/headroom/issues/2542)) ([1cc53c9](https://github.com/headroomlabs-ai/headroom/commit/1cc53c9c92cd4dffaf048dc806cb8c570bdb86b6))
* **proxy/streaming:** tolerate malformed content in _response_to_sse ([#2481](https://github.com/headroomlabs-ai/headroom/issues/2481)) ([77b26c0](https://github.com/headroomlabs-ai/headroom/commit/77b26c093cfb7b5c71a46d5156cb774a2ae889b1))
* **proxy:** keep buffered CCR streams alive ([#2479](https://github.com/headroomlabs-ai/headroom/issues/2479)) ([a2e42fb](https://github.com/headroomlabs-ai/headroom/commit/a2e42fb877642e7eacfcc77655183244823d969e))
* **proxy:** keep core tools and the client's ToolSearch resident for PascalCase clients ([#2647](https://github.com/headroomlabs-ai/headroom/issues/2647)) ([1d29738](https://github.com/headroomlabs-ai/headroom/commit/1d29738818bb40e00847dba46e2f9acce773d3eb))
* **proxy:** offload OpenAI and Gemini tokenizer counting off the event loop ([#2498](https://github.com/headroomlabs-ai/headroom/issues/2498)) ([806d2e4](https://github.com/headroomlabs-ai/headroom/commit/806d2e468ace012ebfa1a0907a679781b5004c72))
* **proxy:** promote Kompress health after runtime load ([#2402](https://github.com/headroomlabs-ai/headroom/issues/2402)) ([54526bc](https://github.com/headroomlabs-ai/headroom/commit/54526bc8586cdeb248d6257dc497136a21b971c0))
* **proxy:** reassemble server_tool_use.input from streamed partial_json ([#2449](https://github.com/headroomlabs-ai/headroom/issues/2449)) ([8c8fae0](https://github.com/headroomlabs-ai/headroom/commit/8c8fae0d0bca75f7f2561136910e40f716be57ab))
* **proxy:** report deferred Kompress status and promote health from cache ([#2564](https://github.com/headroomlabs-ai/headroom/issues/2564)) ([d50cfab](https://github.com/headroomlabs-ai/headroom/commit/d50cfabedca2c4b7d83751adaa8aa7b317f13c7b))
* **proxy:** skip max_tokens rename for backend-routed openai chat ([#2401](https://github.com/headroomlabs-ai/headroom/issues/2401)) ([d6a1af4](https://github.com/headroomlabs-ai/headroom/commit/d6a1af40d5a18f4440a45e342c2d05fee7a642e3))
* **release:** publish Windows wheel + sdist (disable PyPI attestations, [#112](https://github.com/headroomlabs-ai/headroom/issues/112)) ([#2405](https://github.com/headroomlabs-ai/headroom/issues/2405)) ([f9cbdd6](https://github.com/headroomlabs-ai/headroom/commit/f9cbdd6e390714e037832f78c59d00907a26b612))
* **release:** sync generated version metadata on the release branch ([#2659](https://github.com/headroomlabs-ai/headroom/issues/2659)) ([5383c6b](https://github.com/headroomlabs-ai/headroom/commit/5383c6bf2f5209ddfe33cb9bf1c36c0b2e431bcd))
* **rust:** port CJK-aware relevance-query matching to CodeCompressor ([#2634](https://github.com/headroomlabs-ai/headroom/issues/2634)) ([e86c639](https://github.com/headroomlabs-ai/headroom/commit/e86c6390cec4fc0f932b006b36d5b924511a5b0b))
* **security:** exclude compromised ast-grep-cli 0.44.1 (supply-chain trojan) ([#2342](https://github.com/headroomlabs-ai/headroom/issues/2342)) ([494fb5a](https://github.com/headroomlabs-ai/headroom/commit/494fb5a60e15ae1ce425f79f1432827b42923c73))
* **tokenizers:** price Claude against a real BPE (tiktoken o200k) not a char estimate ([#2543](https://github.com/headroomlabs-ai/headroom/issues/2543)) ([285176b](https://github.com/headroomlabs-ai/headroom/commit/285176be54e1d179676dcf205de44d5893f8efa5))
* **transforms/cross-turn-dedup:** don't renumber-fold zero-padded line prefixes ([#2369](https://github.com/headroomlabs-ai/headroom/issues/2369)) ([f4070c4](https://github.com/headroomlabs-ai/headroom/commit/f4070c44cbd65ecf49f2ae81ad26a95296ef552b))
* **transforms/kompress-remote:** keep compress fail-open on malformed 200 ([#2320](https://github.com/headroomlabs-ai/headroom/issues/2320)) ([b759990](https://github.com/headroomlabs-ai/headroom/commit/b75999017fc060a4617077ef86c21ce3249d0842))
* **wrap:** emit bare dotted keys for Codex --config overrides ([#2383](https://github.com/headroomlabs-ai/headroom/issues/2383)) ([f57e959](https://github.com/headroomlabs-ai/headroom/commit/f57e959a506f87f14143d595cae24a1fd6084f66))
* **wrap:** make RTK opt-in (off by default) across wrap subcommands ([#2344](https://github.com/headroomlabs-ai/headroom/issues/2344)) ([44136ed](https://github.com/headroomlabs-ai/headroom/commit/44136ed0427edff338c5d7979b589f8540c9b967))
* **wrap:** skip Serena project setup outside real project roots ([#2574](https://github.com/headroomlabs-ai/headroom/issues/2574)) ([0994ea0](https://github.com/headroomlabs-ai/headroom/commit/0994ea04c869939946b91cbe52ceaf46740786be))
* **wrap:** stop same-port persistent routing during claude unwrap ([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340)) ([#2350](https://github.com/headroomlabs-ai/headroom/issues/2350)) ([cf5fa64](https://github.com/headroomlabs-ai/headroom/commit/cf5fa644b6e019a3ea31b4f48509a63921055253))
### Performance Improvements
* **content_router:** dedupe content detection ([#2419](https://github.com/headroomlabs-ai/headroom/issues/2419)) ([9b016f2](https://github.com/headroomlabs-ai/headroom/commit/9b016f2b64cb50cd50ab68711ab2abdf7d74c8ec))
### Dependencies
* bump the cargo-minor-patch group with 10 updates ([#2284](https://github.com/headroomlabs-ai/headroom/issues/2284)) ([3266ed7](https://github.com/headroomlabs-ai/headroom/commit/3266ed7641cc92f5cae79b1befeb6bee7c96242e))
* bump the npm-minor-patch group across 3 directories with 7 updates ([#2276](https://github.com/headroomlabs-ai/headroom/issues/2276)) ([961866b](https://github.com/headroomlabs-ai/headroom/commit/961866ba7c277b59ccdd51e784de9547a09198af))
### Code Refactoring
* **transforms:** dispatch simple built-in strategies via the compressor registry ([#2399](https://github.com/headroomlabs-ai/headroom/issues/2399)) ([fc9c63f](https://github.com/headroomlabs-ai/headroom/commit/fc9c63f18c1a8414b62ced8b2dd54ad1fe4d1c14))
* **wrap:** retire tokensave; Serena is the code-memory MCP ([#2499](https://github.com/headroomlabs-ai/headroom/issues/2499)) ([5d23a0a](https://github.com/headroomlabs-ai/headroom/commit/5d23a0aec22dacdbd7bf221dafbb17bcf9f10c63))
## [0.32.0](https://github.com/headroomlabs-ai/headroom/compare/v0.31.0...v0.32.0) (2026-07-17)

789
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -58,7 +58,7 @@ 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"
axum = "0.8"
tower = "0.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
pyo3 = { version = "0.29", features = ["abi3-py310"] }

View file

@ -47,7 +47,10 @@ COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY crates/ crates/
COPY headroom/ headroom/
ARG HEADROOM_EXTRAS=proxy,code
# The standalone Dockerfile must support every backend advertised by
# `headroom proxy --backend`, including Bedrock temporary/SSO credentials.
# Those credentials require botocore (GH #1551), supplied by [bedrock].
ARG HEADROOM_EXTRAS=proxy,code,bedrock
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \

14
NOTICE
View file

@ -41,3 +41,17 @@ NumPy (optional dependency)
Copyright (c) 2005-2024, NumPy Developers
Licensed under the BSD 3-Clause License
https://github.com/numpy/numpy
Vendored dashboard assets (headroom/dashboard/static/)
------------------------------------------------------
Tailwind CSS 3.4.17 (Play CDN build) — MIT License
Copyright (c) Tailwind Labs, Inc.
https://github.com/tailwindlabs/tailwindcss
htmx 1.9.10 — Zero-Clause BSD License
Copyright (c) 2020, Big Sky Software
https://github.com/bigskysoftware/htmx
Alpine.js 3.13.3 — MIT License
Copyright (c) 2019-2025 Caleb Porzio and contributors
https://github.com/alpinejs/alpine

View file

@ -107,6 +107,8 @@ headroom dashboard # live savings dashboard (proxy must be
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, installs **Serena** for semantic code navigation, and launches a coding agent session configured to proxy requests through headroom.
Serena is registered at **user scope** (for Claude Code, in `~/.claude.json`), so it stays available in your other projects until you run `headroom unwrap`. To skip it entirely, wrap with `--code-memory none`.
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+**.
@ -231,6 +233,7 @@ shows an **Output Tokens Saved** card next to input compression, labelled
| Cursor | Manual setup | starts proxy and prints base URLs for Cursor settings |
| Aider | ✅ | starts proxy + launches |
| Copilot CLI | ✅ | starts proxy + launches |
| VS Code Copilot | ✅ | transparent proxy; preserves selected model |
| OpenClaw | ✅ | installs as ContextEngine plugin |
| OpenCode | ✅ | injects config · starts proxy + launches |
| Cline | ✅ | starts proxy + injects config |
@ -282,6 +285,52 @@ 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.
### GitHub Copilot in Visual Studio Code
Headroom transparently overrides Copilot's API proxy endpoint, so the normal VS
Code model picker remains authoritative. GPT-5.5, GPT-5.6 Luna/Sol/Terra, Claude
Sonnet/Opus, and other Copilot models keep their original model IDs while traffic
passes through the local compression proxy. Headroom does not patch VS Code or
change Codex settings:
```bash
headroom copilot-auth login
headroom wrap vscode
```
Keep the command running and use Copilot normally. Headroom holds the short-lived
upstream Copilot token only in the proxy process.
See the [cross-platform VS Code Copilot guide](https://headroom-docs.vercel.app/docs/vscode-copilot)
for paths, credential flow, remote-development notes, undo steps, and troubleshooting.
### Claude Code in Visual Studio Code
The official Claude Code extension embeds Claude Code and reads the same user
settings as the CLI. Install Headroom's proxy dependencies, then run the wrapper
from the project you plan to open in VS Code:
```bash
pip install "headroom-ai[proxy]"
headroom wrap vscode-claude
```
On the first run, reload the VS Code window. Keep the wrapper terminal running
while you use the Claude Code panel; inspect the dashboard or proxy log printed
at startup to see requests and savings.
Headroom preserves your Anthropic authentication and selected model.
Press `Ctrl+C` to stop the proxy. Restart the same command before using Claude
Code again, or completely restore the settings that existed before setup:
```bash
headroom unwrap vscode-claude
```
See the
[VS Code Claude Code guide](https://headroom-docs.vercel.app/docs/vscode-claude-code)
for verification, configuration paths, custom profiles, remote development, and
troubleshooting.
## When to use · When to skip
**Great fit if you…**
@ -366,7 +415,7 @@ Everything in this repo stays open source (Apache 2.0). The managed offering is
uv tool install --python 3.13 "headroom-ai[all]" # CLI, isolated app env
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
docker pull ghcr.io/headroomlabs-ai/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+**.
@ -533,12 +582,10 @@ Headroom runs **locally**, covers **every** content type, works with every major
| | 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 |
> **Stack & integrations.** Headroom is the **proxy** — that's what we build and offer, and it compresses everything flowing through it no matter what sits upstream. Our recommended companion is **[Serena](https://github.com/oraios/serena)** (installed by default when you wrap an agent) for semantic code navigation — plus **Ponytail** if you want leaner model output. Everything else is your call: Headroom vendors the third-party [RTK](https://github.com/rtk-ai/rtk) and [lean-ctx](https://github.com/yvgude/lean-ctx) binaries for shell-output rewriting, but we don't own or control either project — swap between them with `HEADROOM_CONTEXT_TOOL`, or turn them off. You're free to attach your own tooling too — code-memory MCP, Graphify, Caveman, or any MCP server — and Headroom compresses downstream of all of it.
> **Stack & integrations.** Headroom is the **proxy** — that's what we build and offer, and it compresses everything flowing through it no matter what sits upstream. Our recommended companion is **[Serena](https://github.com/oraios/serena)** (installed by default when you wrap an agent) for semantic code navigation — plus **Ponytail** if you want leaner model output. Everything else is your call: you're free to attach your own tooling — code-memory MCP, Graphify, Caveman, or any MCP server — and Headroom compresses downstream of all of it.
## Contributing

View file

@ -1,5 +1,14 @@
# Phase G — RTK Breadth + Observability
> **SUPERSEDED.** RTK and lean-ctx were removed from Headroom entirely: the
> `headroom/rtk/` and `headroom/lean_ctx/` packages, all `--rtk` / `--context-tool`
> flags, the wrap-side hooks and hint-file injection, and the proxy-side `rtk gain`
> polling are all gone, and `headroom/context_tool_cleanup.py` uninstalls what
> earlier versions left on disk. The RTK-specific plan below is historical; the
> non-RTK observability items (cache-hit rate, compression ratio, token
> validation) were kept. `docs/rtk-architecture.md`, referenced throughout this
> document, was deleted with the feature.
**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.

View file

@ -92,7 +92,11 @@ No re-scoping needed; revisit after Phase D lands.
## Q9. RTK proxy-side invocation — ever revisit?
**Recommendation:** **No, document the decision in `docs/rtk-architecture.md`** (Phase G PR-G3). The argument:
**Resolved — moot.** RTK was removed from Headroom outright (see
`09-phase-G-rtk-observability.md`), so there is no proxy-side invocation to
revisit. The original recommendation was "no, document the decision in
`docs/rtk-architecture.md`" (that doc was deleted with the feature). The argument
is kept because reasons 13 apply to any future shell-output rewriter:
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.

View file

@ -107,7 +107,7 @@ 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
$env:HEADROOM_DOCKER_IMAGE = "ghcr.io/headroomlabs-ai/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"

View file

@ -1,287 +0,0 @@
"""RTK-loop eval — does Headroom Learn catch a loop and write a guardrail that
would prevent it recurring?
This is the agentic eval for the loop-weighting work. It runs in two phases:
Phase 1 TRIGGER + LEARN
Reproduce an RTK re-fetch loop (a grep whose RTK-truncated output forces the
agent to re-run larger-limit variants), run it through ``SessionAnalyzer``,
and SCORE the resulting guardrail:
produced a loop guardrail was emitted at all
ranked_first it outranks the one-off rules (the weighting works)
names_command the rule identifies the command that looped
prescribes_fix the rule says how to avoid it (fetch full output once)
weight_reflects its savings estimate >= the MEASURED wasted tokens
Phase 2 GUARDRAIL HOLDS
Inject that guardrail as a prior learned pattern, then feed a session where
the agent FOLLOWED it (one full-output fetch, no loop). Re-run the analyzer
and assert NO new loop guardrail is produced for that command i.e. once
the rule exists and is honored, the loop does not re-trigger and Learn does
not need to relearn it.
Runs deterministically by default (a stubbed analyzer LLM so CI is hermetic).
With ``--real`` it drives the real analyzer LLM and scores the actually-generated
rule, using an API key (ANTHROPIC/OPENAI/GEMINI) or an installed CLI backend.
Usage:
python benchmarks/rtk_loop_learn_eval.py # deterministic
python benchmarks/rtk_loop_learn_eval.py --real # real LLM (API key)
HEADROOM_LEARN_CLI=claude python benchmarks/rtk_loop_learn_eval.py --real # via CLI
"""
from __future__ import annotations
import argparse
import os
import sys
from contextlib import nullcontext
from dataclasses import dataclass, field
from pathlib import Path
from unittest.mock import patch
# Allow running as a plain script from the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from headroom.learn.analyzer import SessionAnalyzer # noqa: E402
from headroom.learn.fixtures import rtk_refetch_loop_session # noqa: E402
from headroom.learn.loops import detect_loops # noqa: E402
from headroom.learn.models import ( # noqa: E402
ProjectInfo,
SessionData,
ToolCall,
)
REPETITIONS = 6
# =============================================================================
# Deterministic LLM stub — stands in for the analyzer's _call_llm in CI.
# It mimics a competent model: emits the loop guardrail (under-estimating its
# savings, so the weighting layer has real work to do) plus a one-off rule the
# model would naively rank higher. In Phase 2 it emits NO loop rule, because a
# non-looping guarded session gives it nothing to relearn.
# =============================================================================
def _stub_llm_phase1(digest: str, model: str) -> dict:
return {
"context_file_rules": [
{
"section": "Use uv for Python",
"content": "Use `uv run python` instead of `python3`.",
"estimated_tokens_saved": 900, # model rates the one-off high
"evidence_count": 2,
},
{
"section": "Avoid grep TimeoutError re-fetch loop",
"content": (
"When searching logs for TimeoutError, capture the full "
"result once (grep into a file and read it) instead of "
"re-running grep with larger `head` limits."
),
"estimated_tokens_saved": 150, # simulated low estimate (stub value, not a real-model figure)
"evidence_count": 1,
},
],
"memory_file_rules": [],
}
def _stub_llm_phase2(digest: str, model: str) -> dict:
# Guarded, non-looping session → nothing new to learn about the grep.
return {"context_file_rules": [], "memory_file_rules": []}
# =============================================================================
# Scoring
# =============================================================================
@dataclass
class Scorecard:
checks: dict[str, bool] = field(default_factory=dict)
notes: dict[str, str] = field(default_factory=dict)
def add(self, name: str, passed: bool, note: str = "") -> None:
self.checks[name] = passed
if note:
self.notes[name] = note
@property
def passed(self) -> bool:
return all(self.checks.values())
def render(self) -> str:
width = max(len(k) for k in self.checks)
lines = []
for name, ok in self.checks.items():
mark = "PASS" if ok else "FAIL"
note = f" ({self.notes[name]})" if name in self.notes else ""
lines.append(f" [{mark}] {name.ljust(width)}{note}")
return "\n".join(lines)
def _guarded_session() -> SessionData:
"""A session where the agent followed the guardrail: one full-output fetch,
no re-fetch loop."""
return SessionData(
session_id="guarded",
tool_calls=[
ToolCall(
name="Bash",
tool_call_id="tc_0",
input_data={"command": "grep -rn 'TimeoutError' logs/ > /tmp/hits.txt"},
output="(wrote 1240 matches to /tmp/hits.txt)",
is_error=False,
msg_index=0,
output_bytes=40,
),
ToolCall(
name="Read",
tool_call_id="tc_1",
input_data={"file_path": "/tmp/hits.txt"},
output="logs/app.log:42: TimeoutError ...",
is_error=False,
msg_index=1,
output_bytes=8000,
),
],
)
def run_eval(*, use_real_llm: bool) -> Scorecard:
project = ProjectInfo(
name="rtk-loop-eval",
project_path=Path("/tmp/rtk-loop-eval"),
data_path=Path("/tmp/rtk-loop-eval-data"),
)
card = Scorecard()
# ---- Phase 1: trigger + learn -----------------------------------------
loop_session = rtk_refetch_loop_session(repetitions=REPETITIONS)
loops = detect_loops([loop_session])
measured_waste = loops[0].wasted_tokens if loops else 0
card.add("loop_detected", bool(loops), f"{len(loops)} loop(s), ~{measured_waste:,} tok wasted")
analyzer = SessionAnalyzer(model=None if use_real_llm else "stub")
phase1_ctx = (
nullcontext()
if use_real_llm
else patch("headroom.learn.analyzer._call_llm", _stub_llm_phase1)
)
with phase1_ctx:
result = analyzer.analyze(project, [loop_session])
recs = result.recommendations
loop_recs = [r for r in recs if r.is_loop_guardrail]
card.add("guardrail_produced", bool(loop_recs))
top = recs[0] if recs else None
card.add(
"ranked_first",
bool(top and top.is_loop_guardrail),
"" if (top and top.is_loop_guardrail) else "loop rule did not rank #1",
)
guardrail = loop_recs[0] if loop_recs else None
text = (guardrail.section + " " + guardrail.content).lower() if guardrail else ""
# The rule must identify the LOOPING COMMAND (grep + its output-limit shape),
# not the incidental search string — a good fix generalizes beyond it. (The
# real-LLM run surfaced this: the model wrote a general "grepping logs / `head
# -N` limits" rule and never echoed "TimeoutError", which an earlier
# literal-match check wrongly failed.)
card.add(
"names_command",
"grep" in text and any(k in text for k in ("head", "log", "limit")),
)
card.add(
"prescribes_fix",
any(k in text for k in ("full", "once", "into a file", "instead", "limit")),
)
card.add(
"weight_reflects_waste",
bool(guardrail and guardrail.estimated_tokens_saved >= measured_waste),
""
if (guardrail and guardrail.estimated_tokens_saved >= measured_waste)
else f"savings {getattr(guardrail, 'estimated_tokens_saved', 0)} < waste {measured_waste}",
)
# ---- Phase 2: guardrail holds -----------------------------------------
# Inject the produced guardrail as a prior pattern via the project's
# context file, then analyze a guarded (non-looping) session.
held = True
note = ""
if guardrail:
ctx_path = Path("/tmp/rtk-loop-eval-CLAUDE.md")
ctx_path.write_text(
"<!-- headroom:learn:start -->\n"
f"### {guardrail.section}\n{guardrail.content}\n"
"<!-- headroom:learn:end -->\n",
encoding="utf-8",
)
project.context_file = ctx_path
phase2_ctx = (
nullcontext()
if use_real_llm
else patch("headroom.learn.analyzer._call_llm", _stub_llm_phase2)
)
with phase2_ctx:
held_result = analyzer.analyze(project, [_guarded_session()])
# No NEW loop guardrail should be needed for the (now-guarded) grep.
new_loop_rules = [
r
for r in held_result.recommendations
if r.is_loop_guardrail and "grep" in (r.section + r.content).lower()
]
held = not new_loop_rules
note = "" if held else f"{len(new_loop_rules)} new grep loop rule(s) re-emitted"
else:
held = False
note = "no guardrail from phase 1 to test"
card.add("guardrail_holds", held, note)
return card
def _real_backend_available() -> bool:
"""True when the analyzer can reach a real LLM — API key or installed CLI."""
import shutil
if any(os.environ.get(k) for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY")):
return True
return any(shutil.which(cli) for cli in ("claude", "gemini", "codex"))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--real",
action="store_true",
help="Drive the real analyzer LLM — needs an API key (ANTHROPIC_API_KEY / "
"OPENAI_API_KEY / GEMINI_API_KEY) or an installed CLI backend "
"(claude / gemini / codex; force one with HEADROOM_LEARN_CLI=claude).",
)
args = parser.parse_args()
if args.real and not _real_backend_available():
print(
"--real needs an LLM backend (API key or claude/gemini/codex CLI); "
"falling back to deterministic mode.\n"
)
args.real = False
mode = "REAL LLM" if args.real else "deterministic stub"
print(f"RTK-loop eval — mode: {mode}\n")
card = run_eval(use_real_llm=args.real)
print(card.render())
print()
if card.passed:
print("RESULT: PASS — loop caught, guardrail ranked first, and it holds.")
return 0
print("RESULT: FAIL — see failed checks above.")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -22,13 +22,13 @@ tokenizers = "0.22"
# with `rustls` (no system OpenSSL dep — keeps the binary static-linkable for
# AWS deploys). `from_pretrained` is called once at startup, so blocking is
# fine; if a tokio caller needs it later we can wrap in `spawn_blocking`.
hf-hub = { version = "0.4", default-features = false, features = ["ureq", "rustls-tls"] }
hf-hub = { version = "0.5", default-features = false, features = ["ureq", "rustls-tls"] }
# `md5` for the CCR cache_key. Python's compression_store hashes the original
# diff with MD5 truncated to 24 hex chars; we must match byte-for-byte.
md-5 = "0.10"
# `sha2` for `_hash_field_name` in smart_crusher (SHA256 truncated to 16
# hex chars). Python uses `hashlib.sha256` so we need byte-exact parity.
sha2 = "0.10"
sha2 = "0.11"
# `dashmap` for the CCR storage backend. Concurrent HashMap with sharded
# locking — distinct keys hashed to different shards never contend, so
# multi-worker proxy load doesn't queue on a single Mutex. Lock-free
@ -125,7 +125,7 @@ blake3 = "1"
# image may lag behind. Sub-1 MB binary cost. WAL is enabled at
# connection-open time (see `ccr/backends/sqlite.rs`); no extra feature
# flags required.
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
# `redis` for the optional multi-worker CCR backend. Cfg-gated behind
# the `redis` feature so deploys that don't need it pay no compile
# cost. Default features include the sync `Connection` API used in
@ -196,7 +196,7 @@ redis = ["dep:redis"]
[dev-dependencies]
proptest = "1"
criterion = { version = "0.5", features = ["html_reports"] }
criterion = { version = "0.8", features = ["html_reports"] }
tempfile = "3"
[[bench]]

View file

@ -15,13 +15,16 @@ use std::time::{Duration, Instant};
use dashmap::DashMap;
use crate::ccr::{CcrStore, DEFAULT_CAPACITY, DEFAULT_TTL};
use crate::ccr::{max_lifetime_for, CcrStore, DEFAULT_CAPACITY, DEFAULT_TTL};
/// In-memory CCR store backed by [`DashMap`] for sharded concurrent
/// access.
///
/// - **TTL**: 30 minutes by default. Entries past their TTL are dropped
/// on the next `get` (lazy expiry — no background reaper thread).
/// - **TTL**: 30 minutes by default, treated as an **idle window** —
/// every successful `get` restarts the entry's clock (#2604), bounded
/// by an absolute max lifetime of 8x the idle TTL measured from
/// insertion. Entries past their window are dropped on the next `get`
/// (lazy expiry — no background reaper thread).
/// - **Capacity**: 1000 entries by default. When `put` would push us
/// past capacity, the oldest entry (per insertion order) is evicted.
/// - **Concurrency**: gets and puts on distinct keys do not contend.
@ -36,6 +39,7 @@ pub struct InMemoryCcrStore {
/// they actually evict a real entry.
order: Mutex<VecDeque<String>>,
ttl: Duration,
max_lifetime: Duration,
capacity: usize,
}
@ -43,19 +47,38 @@ pub struct InMemoryCcrStore {
struct Entry {
payload: String,
inserted: Instant,
last_accessed: Instant,
}
impl Entry {
/// Expired when idle past `ttl` OR older (since insertion) than
/// `max_lifetime` — the absolute ceiling that keeps constant access
/// from pinning an entry forever.
fn is_expired(&self, ttl: Duration, max_lifetime: Duration) -> bool {
self.last_accessed.elapsed() > ttl || self.inserted.elapsed() > max_lifetime
}
}
impl InMemoryCcrStore {
/// Default: 1000 entries, 30-minute TTL.
/// Default: 1000 entries, 30-minute idle TTL (8x max lifetime).
pub fn new() -> Self {
Self::with_capacity_and_ttl(DEFAULT_CAPACITY, DEFAULT_TTL)
}
/// `ttl` is the idle window; the absolute max lifetime defaults to
/// 8x that (see [`crate::ccr::DEFAULT_MAX_LIFETIME_MULTIPLIER`]).
pub fn with_capacity_and_ttl(capacity: usize, ttl: Duration) -> Self {
Self::with_capacity_and_ttls(capacity, ttl, max_lifetime_for(ttl))
}
/// Full-control constructor: idle window and absolute max lifetime
/// specified independently.
pub fn with_capacity_and_ttls(capacity: usize, ttl: Duration, max_lifetime: Duration) -> Self {
Self {
map: DashMap::with_capacity(capacity),
order: Mutex::new(VecDeque::with_capacity(capacity)),
ttl,
max_lifetime,
capacity,
}
}
@ -89,8 +112,10 @@ impl CcrStore for InMemoryCcrStore {
// in place, leave the order queue alone. Common when the same
// tool output flows through multiple times in a session.
if let Some(mut existing) = self.map.get_mut(hash) {
let now = Instant::now();
existing.payload = payload.to_string();
existing.inserted = Instant::now();
existing.inserted = now;
existing.last_accessed = now;
return;
}
@ -99,9 +124,11 @@ impl CcrStore for InMemoryCcrStore {
if self.map.len() >= self.capacity {
self.evict_until_under_capacity();
}
let now = Instant::now();
let entry = Entry {
payload: payload.to_string(),
inserted: Instant::now(),
inserted: now,
last_accessed: now,
};
let prev = self.map.insert(hash.to_string(), entry);
if prev.is_none() {
@ -117,9 +144,12 @@ impl CcrStore for InMemoryCcrStore {
}
fn get(&self, hash: &str) -> Option<String> {
// Read path: shard read-lock, check TTL, clone payload out.
// No global lock involvement at all — distinct hashes hash to
// distinct shards and never contend.
// Hit path: shard write-lock (get_mut), check the idle window +
// max-lifetime ceiling, refresh `last_accessed`, clone payload
// out. The TTL is a sliding idle window (#2604): every hit
// restarts the clock, so an entry a session keeps touching does
// not expire mid-burst. Distinct hashes hash to distinct shards
// and never contend.
//
// Lazy expiry uses DashMap's `remove_if` so the check-and-remove
// is atomic on the shard. An earlier 2-step (drop read lock,
@ -130,8 +160,9 @@ impl CcrStore for InMemoryCcrStore {
// load this manifested as "I just stored it; why is it gone?"
// `remove_if` closes the window because the shard write lock
// is held across both the predicate evaluation and the removal.
if let Some(entry) = self.map.get(hash) {
if entry.inserted.elapsed() <= self.ttl {
if let Some(mut entry) = self.map.get_mut(hash) {
if !entry.is_expired(self.ttl, self.max_lifetime) {
entry.last_accessed = Instant::now();
return Some(entry.payload.clone());
}
} else {
@ -143,7 +174,9 @@ impl CcrStore for InMemoryCcrStore {
// and re-fetch its payload.
let was_removed = self
.map
.remove_if(hash, |_, entry| entry.inserted.elapsed() > self.ttl)
.remove_if(hash, |_, entry| {
entry.is_expired(self.ttl, self.max_lifetime)
})
.is_some();
if was_removed {
None

View file

@ -10,10 +10,12 @@
//! # Storage model
//!
//! Each entry maps to a Redis key `ccr:{hash}` containing the original
//! payload bytes, with a `SETEX` TTL applied on every write. Read path
//! is a single `GET`. Redis handles purging via key expiry — no
//! application-side sweep needed (matching the SQLite backend's
//! lazy-purge but at the Redis level).
//! payload bytes, with a `SETEX` TTL applied on every write. The TTL is
//! an **idle window** (#2604): every successful `get` re-arms the key's
//! expiry, bounded by an absolute max lifetime tracked in a companion
//! `ccr:{hash}:born` key whose own expiry marks the ceiling. Redis
//! handles purging via key expiry — no application-side sweep needed
//! (matching the SQLite backend's lazy-purge but at the Redis level).
//!
//! # Concurrency
//!
@ -27,7 +29,7 @@
use redis::Commands;
use crate::ccr::CcrStore;
use crate::ccr::{max_lifetime_for, CcrStore};
/// Key prefix applied to every CCR entry. Configurable per-deployment
/// so multiple proxies sharing one Redis don't collide.
@ -38,6 +40,9 @@ pub struct RedisCcrStore {
client: redis::Client,
key_prefix: String,
default_ttl_seconds: u64,
/// Absolute max lifetime (seconds since `put`) that caps the
/// sliding idle window. Defaults to 8x the idle TTL.
max_lifetime_seconds: u64,
}
impl RedisCcrStore {
@ -59,10 +64,13 @@ impl RedisCcrStore {
// signal.
let mut conn = client.get_connection()?;
let _: String = redis::cmd("PING").query(&mut conn)?;
let max_lifetime_seconds =
max_lifetime_for(std::time::Duration::from_secs(default_ttl_seconds)).as_secs();
Ok(Self {
client,
key_prefix,
default_ttl_seconds,
max_lifetime_seconds,
})
}
@ -70,6 +78,12 @@ impl RedisCcrStore {
format!("{}:{}", self.key_prefix, hash)
}
/// Companion key whose expiry marks the entry's absolute max
/// lifetime; its remaining TTL caps every idle-window re-arm.
fn born_key_for(&self, hash: &str) -> String {
format!("{}:{}:born", self.key_prefix, hash)
}
/// Default TTL (seconds) applied on every `put`.
pub fn default_ttl_seconds(&self) -> u64 {
self.default_ttl_seconds
@ -102,6 +116,20 @@ impl CcrStore for RedisCcrStore {
error = %err,
"ccr_redis_put_failed"
);
return;
}
// Companion max-lifetime marker: its remaining TTL caps every
// idle-window re-arm in `get`, so constant access cannot pin an
// entry past `max_lifetime_seconds`.
let born: redis::RedisResult<()> =
conn.set_ex(self.born_key_for(hash), 1_u8, self.max_lifetime_seconds);
if let Err(err) = born {
tracing::warn!(
target = "ccr.redis",
hash = %hash,
error = %err,
"ccr_redis_put_born_failed"
);
}
}
@ -120,9 +148,9 @@ impl CcrStore for RedisCcrStore {
}
};
let bytes: redis::RedisResult<Option<Vec<u8>>> = conn.get(&key);
match bytes {
Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
Ok(None) => None,
let payload = match bytes {
Ok(Some(bytes)) => String::from_utf8(bytes).ok()?,
Ok(None) => return None,
Err(err) => {
tracing::warn!(
target = "ccr.redis",
@ -130,9 +158,48 @@ impl CcrStore for RedisCcrStore {
error = %err,
"ccr_redis_get_failed"
);
None
return None;
}
};
// Sliding idle window (#2604): re-arm the key's expiry on every
// hit, capped by the companion born-key's remaining lifetime.
let born_key = self.born_key_for(hash);
let born_remaining: i64 = conn.ttl(&born_key).unwrap_or(-1);
let remaining = if born_remaining >= 0 {
born_remaining as u64
} else {
// Legacy entry written by a pre-sliding build (no born key):
// backfill the ceiling from now rather than dropping data.
let backfill: redis::RedisResult<()> =
conn.set_ex(&born_key, 1_u8, self.max_lifetime_seconds);
if let Err(err) = backfill {
tracing::warn!(
target = "ccr.redis",
hash = %hash,
error = %err,
"ccr_redis_born_backfill_failed"
);
}
self.max_lifetime_seconds
};
let new_ttl = self.default_ttl_seconds.min(remaining);
if new_ttl == 0 {
// Past the max lifetime: purge rather than serve a pinned
// entry that should have died.
let _: redis::RedisResult<()> = conn.del(&key);
return None;
}
let rearm: redis::RedisResult<()> = conn.expire(&key, new_ttl as i64);
if let Err(err) = rearm {
tracing::warn!(
target = "ccr.redis",
hash = %hash,
error = %err,
"ccr_redis_ttl_rearm_failed"
);
}
Some(payload)
}
fn len(&self) -> usize {

View file

@ -5,16 +5,21 @@
//!
//! ```sql
//! CREATE TABLE IF NOT EXISTS ccr_entries (
//! hash TEXT PRIMARY KEY,
//! original BLOB NOT NULL,
//! created_at INTEGER NOT NULL, -- unix-seconds
//! ttl_seconds INTEGER NOT NULL
//! hash TEXT PRIMARY KEY,
//! original BLOB NOT NULL,
//! created_at INTEGER NOT NULL, -- unix-seconds
//! ttl_seconds INTEGER NOT NULL, -- idle window, restarted on get
//! last_accessed INTEGER NOT NULL -- unix-seconds
//! );
//! ```
//!
//! On every `get` we lazy-purge stale rows
//! (`WHERE created_at + ttl_seconds <= now`) — no background reaper
//! thread, no cron.
//! The TTL is an **idle window** (#2604): every successful `get`
//! restarts the row's clock via `last_accessed`, bounded by an absolute
//! max lifetime measured from `created_at`. On every `get` we
//! lazy-purge stale rows (`WHERE last_accessed + ttl_seconds < now OR
//! created_at + max_lifetime < now`) — no background reaper thread,
//! no cron. DBs created by pre-sliding builds are migrated in place
//! (the `last_accessed` column is added, backfilled from `created_at`).
//!
//! All hot statements are prepared once on connection setup and reused
//! per call (per realignment build constraint #5: performant). Writes
@ -43,14 +48,17 @@ use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection, OptionalExtension};
use crate::ccr::CcrStore;
use crate::ccr::{max_lifetime_for, CcrStore};
/// SQLite-backed CCR store.
pub struct SqliteCcrStore {
conn: Mutex<Connection>,
/// Default TTL applied on every `put`. Mirrors Python's
/// `compression_store` 30-minute window.
/// Default idle TTL applied on every `put`. Mirrors Python's
/// `compression_store` idle window.
default_ttl_seconds: u64,
/// Absolute max lifetime (seconds since `created_at`) that caps the
/// sliding idle window. Defaults to 8x the idle TTL.
max_lifetime_seconds: u64,
/// Path the connection was opened against — kept for diagnostics
/// and for the proxy-restart simulation test.
path: PathBuf,
@ -58,9 +66,24 @@ pub struct SqliteCcrStore {
impl SqliteCcrStore {
/// Open or create the DB file at `path` and prepare the schema.
/// `default_ttl_seconds` is the idle window; the absolute max
/// lifetime defaults to 8x that (see
/// [`crate::ccr::DEFAULT_MAX_LIFETIME_MULTIPLIER`]).
/// Errors surface to the caller (`from_config`); we never silently
/// fall back to the in-memory backend (`feedback_no_silent_fallbacks.md`).
pub fn open(path: impl AsRef<Path>, default_ttl_seconds: u64) -> rusqlite::Result<Self> {
let max_lifetime =
max_lifetime_for(std::time::Duration::from_secs(default_ttl_seconds)).as_secs();
Self::open_with_ttls(path, default_ttl_seconds, max_lifetime)
}
/// Full-control constructor: idle window and absolute max lifetime
/// specified independently.
pub fn open_with_ttls(
path: impl AsRef<Path>,
default_ttl_seconds: u64,
max_lifetime_seconds: u64,
) -> rusqlite::Result<Self> {
let path_buf = path.as_ref().to_path_buf();
let conn = Connection::open(&path_buf)?;
@ -73,25 +96,49 @@ impl SqliteCcrStore {
conn.execute(
"CREATE TABLE IF NOT EXISTS ccr_entries (
hash TEXT PRIMARY KEY,
original BLOB NOT NULL,
created_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL
hash TEXT PRIMARY KEY,
original BLOB NOT NULL,
created_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
last_accessed INTEGER NOT NULL
)",
[],
)?;
Self::migrate_legacy_schema(&conn)?;
// No secondary index — the schema is one-row-per-PK and the only
// non-PK lookup (the lazy-purge sweep) is a `WHERE` predicate on
// a small table; an index on `created_at + ttl_seconds` would
// cost more than it saves.
// a small table; an index on the expiry expressions would cost
// more than it saves.
Ok(Self {
conn: Mutex::new(conn),
default_ttl_seconds,
max_lifetime_seconds,
path: path_buf,
})
}
/// DBs created before the sliding-TTL change lack `last_accessed`.
/// Add it in place and backfill from `created_at` so legacy rows
/// keep their original expiry baseline rather than being purged or
/// artificially refreshed.
fn migrate_legacy_schema(conn: &Connection) -> rusqlite::Result<()> {
let has_last_accessed = conn
.prepare("SELECT 1 FROM pragma_table_info('ccr_entries') WHERE name = 'last_accessed'")?
.exists([])?;
if !has_last_accessed {
conn.execute(
"ALTER TABLE ccr_entries ADD COLUMN last_accessed INTEGER NOT NULL DEFAULT 0",
[],
)?;
conn.execute(
"UPDATE ccr_entries SET last_accessed = created_at WHERE last_accessed = 0",
[],
)?;
}
Ok(())
}
/// Path the connection was opened against. Test helper.
pub fn path(&self) -> &Path {
&self.path
@ -102,12 +149,18 @@ impl SqliteCcrStore {
self.default_ttl_seconds
}
/// Drop all expired rows. Lazy — invoked from `get`. Returns the
/// Drop all expired rows: idle past their window, or past the
/// absolute max lifetime. Lazy — invoked from `get`. Returns the
/// number of rows purged.
fn purge_expired(conn: &Connection, now: u64) -> rusqlite::Result<usize> {
fn purge_expired(&self, conn: &Connection, now: u64) -> rusqlite::Result<usize> {
// Timestamps have whole-second resolution. Use a strict boundary so
// truncation can extend a cache entry by less than one second but can
// never expire it before the configured idle or lifetime window.
let purged = conn.execute(
"DELETE FROM ccr_entries WHERE created_at + ttl_seconds <= ?1",
params![now as i64],
"DELETE FROM ccr_entries
WHERE last_accessed + ttl_seconds < ?1
OR created_at + ?2 < ?1",
params![now as i64, self.max_lifetime_seconds as i64],
)?;
Ok(purged)
}
@ -120,6 +173,58 @@ impl SqliteCcrStore {
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn get_at(&self, hash: &str, now: u64) -> Option<String> {
let conn = self.conn.lock().expect("ccr sqlite mutex poisoned");
// Lazy purge sweep, then the real lookup. Both happen under
// the same mutex so the row we read is guaranteed not to have
// been just-deleted by another caller.
if let Err(err) = self.purge_expired(&conn, now) {
tracing::warn!(
target = "ccr.sqlite",
error = %err,
"ccr_sqlite_purge_failed"
);
}
let row: Option<Vec<u8>> = conn
.query_row(
"SELECT original FROM ccr_entries
WHERE hash = ?1
AND last_accessed + ttl_seconds >= ?2
AND created_at + ?3 >= ?2",
params![hash, now as i64, self.max_lifetime_seconds as i64],
|r| r.get::<_, Vec<u8>>(0),
)
.optional()
.unwrap_or_else(|err| {
tracing::warn!(
target = "ccr.sqlite",
hash = %hash,
error = %err,
"ccr_sqlite_get_failed"
);
None
});
let row = row?;
// Sliding idle window (#2604): a successful hit restarts the
// row's idle clock. Still under the same mutex as the lookup.
if let Err(err) = conn.execute(
"UPDATE ccr_entries SET last_accessed = ?2 WHERE hash = ?1",
params![hash, now as i64],
) {
tracing::warn!(
target = "ccr.sqlite",
hash = %hash,
error = %err,
"ccr_sqlite_touch_failed"
);
}
String::from_utf8(row).ok()
}
}
impl CcrStore for SqliteCcrStore {
@ -129,12 +234,13 @@ impl CcrStore for SqliteCcrStore {
// Upsert by PK. ON CONFLICT REPLACE matches the in-memory
// backend's idempotent re-store semantics.
let res = conn.execute(
"INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds)
VALUES (?1, ?2, ?3, ?4)
"INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds, last_accessed)
VALUES (?1, ?2, ?3, ?4, ?3)
ON CONFLICT(hash) DO UPDATE SET
original = excluded.original,
created_at = excluded.created_at,
ttl_seconds = excluded.ttl_seconds",
original = excluded.original,
created_at = excluded.created_at,
ttl_seconds = excluded.ttl_seconds,
last_accessed = excluded.last_accessed",
params![
hash,
payload.as_bytes(),
@ -159,39 +265,7 @@ impl CcrStore for SqliteCcrStore {
}
fn get(&self, hash: &str) -> Option<String> {
let now = Self::now_unix_seconds();
let conn = self.conn.lock().expect("ccr sqlite mutex poisoned");
// Lazy purge sweep, then the real lookup. Both happen under
// the same mutex so the row we read is guaranteed not to have
// been just-deleted by another caller.
if let Err(err) = Self::purge_expired(&conn, now) {
tracing::warn!(
target = "ccr.sqlite",
error = %err,
"ccr_sqlite_purge_failed"
);
}
let row: Option<Vec<u8>> = conn
.query_row(
"SELECT original FROM ccr_entries
WHERE hash = ?1 AND created_at + ttl_seconds > ?2",
params![hash, now as i64],
|r| r.get::<_, Vec<u8>>(0),
)
.optional()
.unwrap_or_else(|err| {
tracing::warn!(
target = "ccr.sqlite",
hash = %hash,
error = %err,
"ccr_sqlite_get_failed"
);
None
});
row.and_then(|bytes| String::from_utf8(bytes).ok())
self.get_at(hash, Self::now_unix_seconds())
}
fn len(&self) -> usize {
@ -203,3 +277,56 @@ impl CcrStore for SqliteCcrStore {
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_with_row(
idle_ttl: u64,
max_lifetime: u64,
created_at: u64,
last_accessed: u64,
) -> (tempfile::TempDir, SqliteCcrStore, String) {
let dir = tempfile::tempdir().expect("tempdir");
let store =
SqliteCcrStore::open_with_ttls(dir.path().join("ccr.sqlite"), idle_ttl, max_lifetime)
.expect("open sqlite store");
let hash = "boundary-entry".to_string();
{
let conn = store.conn.lock().expect("ccr sqlite mutex poisoned");
conn.execute(
"INSERT INTO ccr_entries
(hash, original, created_at, ttl_seconds, last_accessed)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
&hash,
b"payload".as_slice(),
created_at as i64,
idle_ttl as i64,
last_accessed as i64,
],
)
.expect("insert boundary row");
}
(dir, store, hash)
}
#[test]
fn exact_idle_ttl_boundary_is_still_valid() {
let (_dir, store, hash) = store_with_row(5, 20, 100, 100);
assert_eq!(store.get_at(&hash, 105).as_deref(), Some("payload"));
assert_eq!(store.get_at(&hash, 111), None);
assert_eq!(store.len(), 0, "expired row must be purged");
}
#[test]
fn exact_max_lifetime_boundary_is_still_valid() {
let (_dir, store, hash) = store_with_row(5, 10, 100, 108);
assert_eq!(store.get_at(&hash, 110).as_deref(), Some("payload"));
assert_eq!(store.get_at(&hash, 111), None);
assert_eq!(store.len(), 0, "expired row must be purged");
}
}

View file

@ -65,6 +65,20 @@ pub const DEFAULT_CAPACITY: usize = 1000;
/// silently converts "lossless with retrieval" into "lossy".
pub const DEFAULT_TTL: Duration = Duration::from_secs(1800);
/// The TTL is an **idle window**, not a wall clock: every successful
/// `get` restarts the entry's clock, so an entry a session keeps
/// touching survives a long multi-agent burst (#2604). To keep
/// constant access from pinning an entry forever, an absolute max
/// lifetime of `DEFAULT_MAX_LIFETIME_MULTIPLIER * ttl` (measured from
/// insertion) caps the sliding window. Mirrors the Python
/// `CompressionStore` semantics.
pub const DEFAULT_MAX_LIFETIME_MULTIPLIER: u32 = 8;
/// Absolute max lifetime for an entry with idle window `idle_ttl`.
pub fn max_lifetime_for(idle_ttl: Duration) -> Duration {
idle_ttl.saturating_mul(DEFAULT_MAX_LIFETIME_MULTIPLIER)
}
/// Compute the canonical CCR key for `payload`. BLAKE3 → first 24 hex
/// chars (96 bits — collision-resistant for the bounded LRU population
/// the proxy will hold). Centralized here so every call site (live-zone

View file

@ -7,6 +7,7 @@ pub mod compression_policy;
#[cfg(feature = "ml")]
mod onnx_cpu;
pub mod relevance;
pub mod rollout;
pub mod signals;
pub mod tokenizer;
pub mod transforms;

View file

@ -0,0 +1,444 @@
//! Deterministic runtime-rollout policy and provenance.
//!
//! Rollout channels control behavior in an already-built artifact. They do not
//! select a package, release candidate, or distribution version. Composition
//! roots resolve one immutable snapshot and inject its concrete decisions.
use serde::Serialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::str::FromStr;
pub const ROLLOUT_SCHEMA_VERSION: u32 = 1;
pub const ROLLOUT_POLICY_VERSION: &str = "1";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RolloutChannel {
#[default]
Stable,
Beta,
Canary,
Dev,
}
impl RolloutChannel {
pub fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
Self::Canary => "canary",
Self::Dev => "dev",
}
}
pub fn allows(self, required: Self) -> bool {
self >= required
}
}
impl FromStr for RolloutChannel {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"" | "stable" | "prod" | "production" => Ok(Self::Stable),
"beta" | "preview" => Ok(Self::Beta),
"canary" | "nightly" => Ok(Self::Canary),
"dev" | "development" => Ok(Self::Dev),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Feature {
NativeBedrock,
OpenAiResponsesStreaming,
CanaryProbe,
}
const ALL_FEATURES: [Feature; 3] = [
Feature::CanaryProbe,
Feature::NativeBedrock,
Feature::OpenAiResponsesStreaming,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct FeatureSpec {
pub name: &'static str,
pub available_in: RolloutChannel,
pub default_enabled_in: Option<RolloutChannel>,
}
impl Feature {
pub fn spec(self) -> FeatureSpec {
match self {
Self::NativeBedrock => FeatureSpec {
name: "native_bedrock",
available_in: RolloutChannel::Stable,
default_enabled_in: Some(RolloutChannel::Stable),
},
Self::OpenAiResponsesStreaming => FeatureSpec {
name: "openai_responses_streaming",
available_in: RolloutChannel::Stable,
default_enabled_in: Some(RolloutChannel::Stable),
},
Self::CanaryProbe => FeatureSpec {
name: "canary_probe",
available_in: RolloutChannel::Canary,
default_enabled_in: None,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FeatureDecisionReason {
Default,
Explicit,
LegacyAlias,
Disabled,
BlockedByChannel,
UnsafeOverride,
NotRequested,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RolloutConfig {
pub channel: RolloutChannel,
pub requested: BTreeSet<String>,
pub disabled: BTreeSet<String>,
pub unsafe_allow_unstable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FeatureDecision {
pub name: &'static str,
pub available_in: RolloutChannel,
pub default_enabled_in: Option<RolloutChannel>,
pub requested: bool,
pub disabled: bool,
pub enabled: bool,
#[serde(rename = "decision")]
pub reason: FeatureDecisionReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RolloutSnapshot {
pub schema_version: u32,
pub policy_version: &'static str,
pub registry_digest: String,
pub config: RolloutConfig,
pub decisions: Vec<FeatureDecision>,
}
impl Default for RolloutSnapshot {
fn default() -> Self {
Self::from_parts("stable", "", "", false)
}
}
impl RolloutSnapshot {
pub fn from_parts(
channel: &str,
requested: &str,
disabled: &str,
unsafe_allow_unstable: bool,
) -> Self {
Self::from_parts_with_explicit(channel, requested, disabled, unsafe_allow_unstable, &[])
}
pub fn from_parts_with_explicit(
channel: &str,
requested: &str,
disabled: &str,
unsafe_allow_unstable: bool,
explicit: &[Feature],
) -> Self {
let parsed_channel = RolloutChannel::from_str(channel).unwrap_or_else(|_| {
tracing::warn!(channel, "unknown rollout channel; falling back to stable");
RolloutChannel::Stable
});
let valid_names: BTreeSet<_> = ALL_FEATURES
.iter()
.map(|feature| feature.spec().name.to_owned())
.collect();
let mut requested_names = validated_names(requested, "requested", &valid_names);
requested_names.extend(
explicit
.iter()
.map(|feature| feature.spec().name.to_owned()),
);
let disabled_names = validated_names(disabled, "disabled", &valid_names);
let config = RolloutConfig {
channel: parsed_channel,
requested: requested_names,
disabled: disabled_names,
unsafe_allow_unstable,
};
let decisions = ALL_FEATURES
.iter()
.map(|feature| resolve_feature(*feature, &config))
.collect();
Self {
schema_version: ROLLOUT_SCHEMA_VERSION,
policy_version: ROLLOUT_POLICY_VERSION,
registry_digest: registry_digest(),
config,
decisions,
}
}
pub fn decision(&self, feature: Feature) -> &FeatureDecision {
let name = feature.spec().name;
self.decisions
.iter()
.find(|decision| decision.name == name)
.expect("every registered feature has a decision")
}
pub fn is_enabled(&self, feature: Feature, _explicit: bool) -> bool {
self.decision(feature).enabled
}
pub fn enabled(&self) -> BTreeSet<String> {
self.decisions
.iter()
.filter(|decision| decision.enabled)
.map(|decision| decision.name.to_owned())
.collect()
}
pub fn qualification_eligible(&self) -> bool {
!self.config.unsafe_allow_unstable
}
fn canonical_value(&self) -> Value {
json!({
"schema_version": self.schema_version,
"policy_version": self.policy_version,
"channel": self.config.channel,
"unsafe_override": self.config.unsafe_allow_unstable,
"registry_digest": self.registry_digest,
"features": self.decisions,
})
}
pub fn snapshot_digest(&self) -> String {
digest_value(&self.canonical_value())
}
pub fn to_value(&self) -> Value {
let mut value = self.canonical_value();
let object = value
.as_object_mut()
.expect("rollout snapshot is an object");
object.insert("snapshot_digest".into(), json!(self.snapshot_digest()));
object.insert(
"qualification_eligible".into(),
json!(self.qualification_eligible()),
);
if !self.qualification_eligible() {
object.insert(
"qualification_ineligible_reason".into(),
json!("unsafe_rollout_override_active"),
);
}
value
}
}
fn resolve_feature(feature: Feature, config: &RolloutConfig) -> FeatureDecision {
let spec = feature.spec();
let requested = config.requested.contains(spec.name);
let disabled = config.disabled.contains(spec.name);
let normally_available = config.channel.allows(spec.available_in);
let (enabled, reason) = if disabled {
(false, FeatureDecisionReason::Disabled)
} else if requested && !normally_available && !config.unsafe_allow_unstable {
(false, FeatureDecisionReason::BlockedByChannel)
} else if requested && !normally_available {
(true, FeatureDecisionReason::UnsafeOverride)
} else if requested {
(true, FeatureDecisionReason::Explicit)
} else if spec
.default_enabled_in
.is_some_and(|minimum| config.channel.allows(minimum))
{
(true, FeatureDecisionReason::Default)
} else {
(false, FeatureDecisionReason::NotRequested)
};
FeatureDecision {
name: spec.name,
available_in: spec.available_in,
default_enabled_in: spec.default_enabled_in,
requested,
disabled,
enabled,
reason,
}
}
fn validated_names(raw: &str, source: &str, valid: &BTreeSet<String>) -> BTreeSet<String> {
let names: BTreeSet<_> = split_feature_names(raw).into_iter().collect();
for unknown in names.difference(valid) {
tracing::warn!(
feature = unknown,
source,
"unknown rollout feature; ignoring (fail-closed)"
);
}
names.intersection(valid).cloned().collect()
}
pub fn split_feature_names(raw: &str) -> Vec<String> {
raw.replace(';', ",")
.split(',')
.filter_map(|part| {
let normalized = normalize_feature_name(part);
(!normalized.is_empty()).then_some(normalized)
})
.collect()
}
pub fn normalize_feature_name(raw: impl AsRef<str>) -> String {
raw.as_ref().trim().to_ascii_lowercase().replace('-', "_")
}
pub fn registry_digest() -> String {
let registry: Vec<_> = ALL_FEATURES.iter().map(|feature| feature.spec()).collect();
digest_value(&serde_json::to_value(registry).expect("registry is serializable"))
}
pub fn feature_names() -> BTreeSet<&'static str> {
ALL_FEATURES
.iter()
.map(|feature| feature.spec().name)
.collect()
}
fn digest_value(value: &Value) -> String {
let canonical = serde_json::to_vec(value).expect("rollout provenance is serializable");
let digest = Sha256::digest(canonical);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
format!("sha256:{hex}")
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct PolicyVector {
channel: String,
requested: bool,
disabled: bool,
#[serde(rename = "unsafe")]
unsafe_override: bool,
enabled: bool,
decision: String,
}
#[test]
fn channel_order_matches_python_policy() {
assert!(RolloutChannel::Dev.allows(RolloutChannel::Canary));
assert!(RolloutChannel::Canary.allows(RolloutChannel::Beta));
assert!(!RolloutChannel::Stable.allows(RolloutChannel::Canary));
}
#[test]
fn stable_blocks_explicit_canary_feature_with_reason() {
let rollout = RolloutSnapshot::from_parts("stable", "canary_probe", "", false);
let decision = rollout.decision(Feature::CanaryProbe);
assert!(!decision.enabled);
assert_eq!(decision.reason, FeatureDecisionReason::BlockedByChannel);
}
#[test]
fn default_enabled_feature_has_default_reason() {
let rollout = RolloutSnapshot::default();
let decision = rollout.decision(Feature::NativeBedrock);
assert!(decision.enabled);
assert_eq!(decision.reason, FeatureDecisionReason::Default);
}
#[test]
fn unsafe_override_crosses_boundary_and_is_ineligible() {
let rollout = RolloutSnapshot::from_parts("stable", "canary_probe", "", true);
assert_eq!(
rollout.decision(Feature::CanaryProbe).reason,
FeatureDecisionReason::UnsafeOverride
);
assert!(!rollout.qualification_eligible());
assert_eq!(
rollout.to_value()["qualification_ineligible_reason"],
"unsafe_rollout_override_active"
);
}
#[test]
fn disable_beats_default_explicit_and_unsafe() {
for unsafe_override in [false, true] {
let rollout = RolloutSnapshot::from_parts(
"stable",
"native_bedrock",
"native-bedrock",
unsafe_override,
);
assert_eq!(
rollout.decision(Feature::NativeBedrock).reason,
FeatureDecisionReason::Disabled
);
}
}
#[test]
fn provenance_digests_are_deterministic_and_policy_sensitive() {
let first = RolloutSnapshot::from_parts("canary", "canary_probe", "", false);
let second = RolloutSnapshot::from_parts("canary", "canary_probe", "", false);
let changed = RolloutSnapshot::from_parts("stable", "canary_probe", "", false);
assert_eq!(first.registry_digest, second.registry_digest);
assert_eq!(first.snapshot_digest(), second.snapshot_digest());
assert_ne!(first.snapshot_digest(), changed.snapshot_digest());
}
#[test]
fn invalid_inputs_fail_closed() {
let rollout = RolloutSnapshot::from_parts("stabel", "unknown", "unknown", false);
assert_eq!(rollout.config.channel, RolloutChannel::Stable);
assert!(rollout.config.requested.is_empty());
assert!(rollout.config.disabled.is_empty());
}
#[test]
fn shared_python_rust_policy_vectors() {
let vectors: Vec<PolicyVector> = serde_json::from_str(include_str!(
"../../../tests/fixtures/rollout_policy_vectors.json"
))
.unwrap();
for vector in vectors {
let requested = if vector.requested { "canary_probe" } else { "" };
let disabled = if vector.disabled { "canary_probe" } else { "" };
let rollout = RolloutSnapshot::from_parts(
&vector.channel,
requested,
disabled,
vector.unsafe_override,
);
let decision = rollout.decision(Feature::CanaryProbe);
assert_eq!(decision.enabled, vector.enabled);
assert_eq!(
serde_json::to_value(decision.reason).unwrap(),
vector.decision
);
}
}
}

View file

@ -1,12 +1,35 @@
//! Character-density estimator. Used as a fallback for any tokenizer family
//! we haven't wired in yet (Anthropic Claude, Google Gemini, Cohere, …).
//!
//! Mirrors `headroom.tokenizers.estimator.EstimatingTokenCounter`. The formula
//! is `ceil(chars / chars_per_token)`. `chars` is *Unicode scalar count*, not
//! byte length, to match Python's `len(text)` semantics on str.
//! Mirrors `headroom.tokenizers.estimator.EstimatingTokenCounter`. Latin chars
//! are priced at `chars_per_token`; dense scripts (CJK / Kana / Hangul / full-
//! width) are priced separately at `CHARS_PER_TOKEN_CJK`, since they tokenize at
//! ~1 token/char and the Latin ratio under-counts them 2-4x. `chars` is a
//! *Unicode scalar count*, not byte length, to match Python's `len(text)`.
use super::{Backend, Tokenizer};
/// Chars-per-token for dense scripts. Byte-identical with Python
/// `EstimatingTokenCounter.CHARS_PER_TOKEN_CJK`.
const CHARS_PER_TOKEN_CJK: f64 = 1.5;
/// True for a "dense-script" codepoint (CJK ideographs + punctuation, Kana,
/// Hangul, CJK compatibility, half/full-width forms, CJK Ext-A/B). Ranges kept
/// byte-identical with Python `EstimatingTokenCounter.CJK_PATTERN`.
fn is_dense_script(c: char) -> bool {
matches!(
c as u32,
0x3000..=0x303F // CJK symbols and punctuation
| 0x3040..=0x30FF // Hiragana + Katakana
| 0x3400..=0x4DBF // CJK Unified Ideographs Ext A
| 0x4E00..=0x9FFF // CJK Unified Ideographs
| 0xAC00..=0xD7AF // Hangul syllables
| 0xF900..=0xFAFF // CJK compatibility ideographs
| 0xFF00..=0xFFEF // Half/full-width forms
| 0x20000..=0x2A6DF // CJK Unified Ideographs Ext B
)
}
#[derive(Debug, Clone, Copy)]
pub struct EstimatingCounter {
chars_per_token: f64,
@ -42,15 +65,15 @@ impl Tokenizer for EstimatingCounter {
if text.is_empty() {
return 0;
}
// Match Python `EstimatingTokenCounter.count_text`:
// max(1, int(len(text) / chars_per_token + 0.5))
// Python `int()` truncates toward zero; for non-negative inputs that's
// identical to `as usize` saturating-cast semantics in Rust >= 1.45.
// Adding 0.5 then truncating yields round-half-up. We previously used
// ceil, which over-counted in the middle of the range (e.g. "aaaaa"
// at 4.0 cpt returned 2 here vs 1 in Python).
let chars = text.chars().count() as f64;
let raw = (chars / self.chars_per_token + 0.5) as usize;
// Match Python `EstimatingTokenCounter.count_text` (fixed-ratio path):
// cjk = count_dense_script(text); other = len(text) - cjk
// max(1, int(other / chars_per_token + cjk / CHARS_PER_TOKEN_CJK + 0.5))
// Dense scripts tokenize at ~1 token/char, so the Latin `chars_per_token`
// under-counts them; price them separately. `int()` truncates toward
// zero (== `as usize` for non-negative); the `+ 0.5` gives round-half-up.
let cjk = text.chars().filter(|&c| is_dense_script(c)).count();
let other = (text.chars().count() - cjk) as f64;
let raw = (other / self.chars_per_token + cjk as f64 / CHARS_PER_TOKEN_CJK + 0.5) as usize;
raw.max(1)
}
@ -104,6 +127,27 @@ mod tests {
assert_eq!(est.count_text("🦀🦀🦀🦀"), 1);
}
#[test]
fn dense_scripts_priced_at_cjk_ratio() {
let est = EstimatingCounter::default(); // 4.0 for Latin
// Pure CJK: cjk=3, other=0 -> 0/4 + 3/1.5 + 0.5 = 2.5 -> int -> 2
assert_eq!(est.count_text("数据库"), 2);
// 7 CJK -> 7/1.5 + 0.5 = 5.16 -> 5 (the old flat 7/4 -> 2 under-counted ~2.5x)
assert_eq!(est.count_text("数据库连接失败"), 5);
// Kana is dense: 3 hiragana -> 3/1.5 + 0.5 = 2.5 -> 2
assert_eq!(est.count_text("ひらが"), 2);
// Full-width Latin is dense (U+FF00-FFEF): -> 2, vs plain "API" -> 1
assert_eq!(est.count_text(""), 2);
assert_eq!(est.count_text("API"), 1);
}
#[test]
fn mixed_ascii_and_cjk_prices_each_separately() {
let est = EstimatingCounter::default();
// "api数据": other=3, cjk=2 -> 3/4 + 2/1.5 + 0.5 = 0.75+1.33+0.5 = 2.58 -> 2
assert_eq!(est.count_text("api数据"), 2);
}
#[test]
fn min_is_one_for_non_empty_input() {
let est = EstimatingCounter::default();

View file

@ -413,6 +413,59 @@ fn get_definition_name(node: Node, code: &str) -> Option<String> {
None
}
/// Tokenize a relevance query for symbol-name matching (CJK-aware).
/// Mirrors `_query_context_tokens`: returns (word set, lowercased query,
/// has_cjk). Symbol names are ASCII identifiers; CJK relevance queries have
/// no spaces and use CJK/full-width punctuation, so an ASCII-only delimiter
/// class would collapse the whole query into one blob and never isolate an
/// ASCII name the user asked to keep. CJK/full-width punctuation and the
/// ideographic space are therefore delimiters too.
fn query_context_tokens(context: &str) -> (BTreeSet<String>, String, bool) {
if context.is_empty() {
return (BTreeSet::new(), String::new(), false);
}
static DELIMS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let delims = DELIMS.get_or_init(|| {
// Same class as Python `_CONTEXT_DELIMS`.
regex::Regex::new(r#"[\s,;:.()\[\]{}"',、;:。.!?()【】「」『』《》〈〉·…— ]+"#)
.unwrap()
});
static CJK: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let cjk = CJK.get_or_init(|| {
// Same class as Python `_CJK_CHARS`:
// U+3000-U+9FFF, U+AC00-U+D7AF (Hangul), U+FF00-U+FFEF (full-width).
regex::Regex::new(r"[\u{3000}-\u{9FFF}\u{AC00}-\u{D7AF}\u{FF00}-\u{FFEF}]").unwrap()
});
let lowered = context.to_lowercase();
let words: BTreeSet<String> = delims
.split(&lowered)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let has_cjk = cjk.is_match(&lowered);
(words, lowered, has_cjk)
}
/// Whether the relevance query names this symbol. Mirrors `_symbol_in_context`:
/// exact token match, or a substring fallback gated by len>3 (in characters,
/// like Python's `len`) for ASCII queries but relaxed for CJK queries — a
/// short ASCII name glued to CJK has no delimiter to isolate it, so the
/// exact match can't fire and the guard would wrongly drop it.
fn symbol_in_context(
name_lower: &str,
words: &BTreeSet<String>,
context_lower: &str,
has_cjk: bool,
) -> bool {
if words.is_empty() || name_lower.is_empty() {
return false;
}
if words.contains(name_lower) {
return true;
}
context_lower.contains(name_lower) && (name_lower.chars().count() > 3 || has_cjk)
}
fn is_public_symbol(name: &str, language: CodeLanguage) -> bool {
if name.is_empty() {
return false;
@ -1066,18 +1119,8 @@ impl CodeAwareCompressor {
ref_counts.insert(qname.clone(), (count - def_count).max(0));
}
// Context words (empty when context is "").
let context_lower = context.to_lowercase();
let context_words: BTreeSet<String> = if context.is_empty() {
BTreeSet::new()
} else {
static SPLIT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = SPLIT.get_or_init(|| regex::Regex::new(r#"[\s,;:.()\[\]{}"']+"#).unwrap());
re.split(&context_lower)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
};
// Context words (empty when context is ""). Mirrors `_query_context_tokens`.
let (context_words, context_lower, context_has_cjk) = query_context_tokens(context);
// Raw importance signals per symbol.
let mut raw_signals: Vec<(String, f64)> = Vec::new();
@ -1106,13 +1149,14 @@ impl CodeAwareCompressor {
raw += 1.0;
}
if !context_words.is_empty() {
let name_lower = short.to_lowercase();
if context_words.contains(&name_lower)
|| (name_lower.chars().count() > 3 && context_lower.contains(&name_lower))
{
raw += 3.0;
}
// Context boost: the relevance query named this symbol.
if symbol_in_context(
&short.to_lowercase(),
&context_words,
&context_lower,
context_has_cjk,
) {
raw += 3.0;
}
raw_signals.push((qname.clone(), raw));
}
@ -1866,6 +1910,107 @@ mod tests {
assert_eq!(lang, CodeLanguage::Unknown);
}
// CJK-aware relevance-query matching. Mirrors
// tests/test_transforms/test_code_compressor_cjk.py (Python reference).
#[test]
fn cjk_query_isolates_wrapped_ascii_symbol() {
// Full-width parens around the name must still tokenize parse_config out.
let (words, lowered, has_cjk) =
query_context_tokens("请重点保留parse_config的解析配置");
assert!(has_cjk);
assert!(words.contains("parse_config"));
assert!(symbol_in_context("parse_config", &words, &lowered, has_cjk));
}
#[test]
fn cjk_query_matches_short_ascii_name_glued_to_cjk() {
// 'db' (len 2) glued to CJK has no delimiter to isolate it; the len>3
// guard is relaxed for CJK so the substring fallback still matches.
let (words, lowered, has_cjk) = query_context_tokens("请保留db相关的逻辑");
assert!(has_cjk);
assert!(symbol_in_context("db", &words, &lowered, has_cjk));
}
#[test]
fn english_short_name_substring_still_gated() {
// ASCII query unchanged: a short name that is only a substring (not a
// token) of an English query must NOT match (avoids spurious boosts).
let (words, lowered, has_cjk) = query_context_tokens("keep the database helper");
assert!(!has_cjk);
assert!(!symbol_in_context("db", &words, &lowered, has_cjk));
}
#[test]
fn english_exact_token_match_unchanged() {
let (words, lowered, has_cjk) = query_context_tokens("keep parse_config and helper");
assert!(!has_cjk);
assert!(symbol_in_context("parse_config", &words, &lowered, has_cjk));
assert!(symbol_in_context("helper", &words, &lowered, has_cjk));
}
#[test]
fn english_long_name_substring_fallback_unchanged() {
// ASCII path, len>3 substring fallback: 'parse_config' is not a
// standalone token but is a substring of 'parse_configs' -> must match.
let (words, lowered, has_cjk) = query_context_tokens("parse_configs and related helpers");
assert!(!has_cjk);
assert!(!words.contains("parse_config"));
assert!(symbol_in_context("parse_config", &words, &lowered, has_cjk));
}
#[test]
fn empty_context_matches_nothing() {
let (words, lowered, has_cjk) = query_context_tokens("");
assert!(words.is_empty());
assert_eq!(lowered, "");
assert!(!has_cjk);
assert!(!symbol_in_context("foo", &words, &lowered, has_cjk));
}
#[test]
fn guard_counts_chars_not_bytes() {
// Python's len() counts characters. A 4-char name that is >3 in chars
// must take the substring fallback on an ASCII query even though a
// byte-length comparison would agree here; conversely a 3-char name
// must not, even when it is many bytes away from any CJK.
let (words, lowered, has_cjk) = query_context_tokens("prefer the runs_fast variant");
assert!(!has_cjk);
assert!(symbol_in_context("runs", &words, &lowered, has_cjk));
assert!(!symbol_in_context("run", &words, &lowered, has_cjk));
}
/// Symmetric pair of Python functions: identical raw importance signals,
/// so any score difference comes only from the context boost.
const CJK_BOOST_CODE: &str = "import os\n\n\
def run(config):\n value = config.get(\"alpha\")\n result = value + 1\n total = result * 2\n scaled = total - value\n merged = scaled + result\n print(merged)\n print(scaled)\n print(total)\n return merged\n\n\
def keep(config):\n value = config.get(\"beta\")\n result = value + 2\n total = result * 3\n scaled = total - value\n merged = scaled + result\n print(merged)\n print(scaled)\n print(total)\n return merged\n";
fn score_of(result: &CodeCompressionResult, name: &str) -> f64 {
result
.symbol_scores
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| *v)
.unwrap_or_else(|| panic!("no score for {name}: {:?}", result.symbol_scores))
}
#[test]
fn cjk_context_boosts_named_symbol_end_to_end() {
// Python reference: a CJK query with no spaces still boosts the ASCII
// symbol it names ("run" glued to CJK, len 3 <= guard, has_cjk relaxes it).
let c = CodeAwareCompressor::new(CodeCompressorConfig::default());
let r = c.compress_with(CJK_BOOST_CODE, Some("python"), "修复run函数的报错");
assert_eq!(score_of(&r, "run"), 1.0, "run must get the context boost");
assert_eq!(score_of(&r, "keep"), 0.0);
// ASCII query unchanged: "run" is only a substring of "runner" and the
// len>3 guard is NOT relaxed without CJK -> no boost, symmetric scores.
let r = c.compress_with(CJK_BOOST_CODE, Some("python"), "fix the runner");
assert_eq!(score_of(&r, "run"), 0.5);
assert_eq!(score_of(&r, "keep"), 0.5);
}
#[test]
fn empty_and_short_passthrough() {
let c = CodeAwareCompressor::new(CodeCompressorConfig::default());

View file

@ -566,8 +566,17 @@ fn code_compressor() -> &'static CodeAwareCompressor {
INSTANCE.get_or_init(|| CodeAwareCompressor::new(CodeCompressorConfig::default()))
}
// Source-code compression changes request content, so it remains disabled
// unless an operator explicitly enables it at proxy startup.
static CODE_COMPRESSOR_ENABLED: AtomicBool = AtomicBool::new(false);
/// Enable or disable AST-aware `SourceCode` compression process-wide.
pub fn set_code_compressor_enabled(enabled: bool) {
CODE_COMPRESSOR_ENABLED.store(enabled, Ordering::Relaxed);
}
// Process-wide gate for the Kompress (PlainText) compressor. Default OFF:
// unlike the always-on structural compressors and CodeCompressor, Kompress
// unlike the structural compressors and opt-in CodeCompressor, Kompress
// carries a ~261 MB ONNX model, so an operator must opt in before it is ever
// loaded. Mirrors the Python reference's `config.enable_kompress`. The proxy
// sets this once at startup from `--enable-kompress`.
@ -626,7 +635,9 @@ fn kompress() -> Option<&'static Kompress> {
/// or not cached). Idempotent: the underlying `OnceLock` loads at most once.
pub fn warm_live_zone_compressors() -> bool {
// CodeCompressor: statically-linked grammars, trivial to construct.
let _ = code_compressor();
if CODE_COMPRESSOR_ENABLED.load(Ordering::Relaxed) {
let _ = code_compressor();
}
// Kompress: perform the (potentially slow) load here, off the request path.
// `Some` iff enabled AND the model was already in the HF cache.
@ -1525,6 +1536,11 @@ fn dispatch_compressor(text: &str, content_type: ContentType) -> DispatchResult
}
}
ContentType::SourceCode => {
if !CODE_COMPRESSOR_ENABLED.load(Ordering::Relaxed) {
return DispatchResult::NoOp {
content_type: content_type.as_str(),
};
}
let result = code_compressor().compress(text);
// The engine returns the input unchanged for passthrough
// branches (below min-tokens, UNKNOWN language, invalid-syntax

View file

@ -54,7 +54,7 @@ pub use kompress::{
};
pub use live_zone::{
compress_anthropic_live_zone, compress_openai_chat_live_zone,
compress_openai_responses_live_zone, set_kompress_enabled,
compress_openai_responses_live_zone, set_code_compressor_enabled, set_kompress_enabled,
summarize_openai_responses_no_change_reason, warm_live_zone_compressors, AuthMode, BlockAction,
BlockOutcome, CompressionManifest, ExclusionReason, LiveZoneError, LiveZoneOutcome,
};

View file

@ -21,6 +21,12 @@ use serde_json::Value;
use super::ir::OpaqueKind;
/// Prefix of every CCR marker this crate emits (`<<ccr:HASH,KIND,SIZE>>`,
/// `<<ccr:HASH N_rows_offloaded>>`, `<<ccr:HASH>>`). Content carrying one is
/// already-compressed output and must never be offloaded again — see
/// [`classify_string`].
const CCR_MARKER_PREFIX: &str = "<<ccr:";
/// Per-cell classification result.
#[derive(Debug, Clone, PartialEq)]
pub enum CellClass {
@ -86,6 +92,24 @@ pub fn classify_cell(value: &Value, cfg: &ClassifyConfig) -> CellClass {
}
fn classify_string(s: &str, cfg: &ClassifyConfig) -> CellClass {
// Never re-offload our own output (#2694). A string carrying a
// `<<ccr:…>>` marker is *compressed output*, not source content: the
// real bytes already live in the store under the marker's hash. Hashing
// it again would stash the MARKER as the new entry's "original", so
// `headroom_retrieve` hands the caller a placeholder instead of the
// data — silent, permanent loss of whatever the inner marker pointed at
// (its hash is no longer visible anywhere the model can reach). Same
// defect class as #1209 (tag placeholders persisted as originals).
//
// This is the shared choke point for both offload sites — the document
// walker (`walker::walk_string`) and the table compactor
// (`compactor::cell_from_value`) — so one guard covers both. A cell that
// is already a marker also renders no smaller, so keeping it Scalar
// costs nothing.
if s.contains(CCR_MARKER_PREFIX) {
return CellClass::Scalar;
}
// Stringified-JSON check first. Cheap fast-path: must start with
// `{` or `[` (after optional whitespace) — skip strings that
// can't possibly be JSON containers. Parsing `"123"` would

View file

@ -34,7 +34,7 @@ use std::sync::Arc;
use serde_json::{Map, Value};
use super::classifier::{classify_cell, CellClass};
use super::compactor::{compact, CompactConfig};
use super::compactor::{compact_with_store, CompactConfig};
use super::formatter::{CsvSchemaFormatter, Formatter};
use super::ir::OpaqueKind;
use crate::ccr::CcrStore;
@ -116,8 +116,12 @@ fn walk_array(items: Vec<Value>, ctx: &DocumentCompactor) -> Value {
// becomes a rendered string before the outer table sees it.
let inner: Vec<Value> = items.into_iter().map(|i| walk(i, ctx)).collect();
// Then try the array as a whole.
let c = compact(&inner, &ctx.config);
// Then try the array as a whole. `compact_with_store` (not the
// store-less `compact`) is required: the table compactor substitutes
// opaque cells with `<<ccr:HASH,…>>` markers, and without the store
// those markers point at a key nothing ever wrote — `headroom_retrieve`
// 404s and the cell's bytes are gone for good (#2694).
let c = compact_with_store(&inner, &ctx.config, ctx.ccr_store.as_ref());
if c.was_compacted() {
Value::String(ctx.formatter.format(&c))
} else {

View file

@ -523,7 +523,11 @@ impl SmartCrusher {
rows.extend(arr.iter().cloned());
}
let result = self.crush_array(&rows, query_context, bias);
// `arr` (not `rows`) is what the CCR marker must
// resolve to: `rows` may already be prose-
// compressed / marker-substituted by the hook.
let result =
self.crush_array_with_source(&rows, arr, query_context, bias);
// Lossless path won → substitute the array
// with the compacted string in place. This
// makes the lossless win visible to the
@ -759,6 +763,27 @@ impl SmartCrusher {
/// 7. `execute_plan(plan, items)` → result.
/// 8. Strategy info = `analysis.recommended_strategy.as_str()`.
pub fn crush_array(&self, items: &[Value], query_context: &str, bias: f64) -> CrushArrayResult {
self.crush_array_with_source(items, items, query_context, bias)
}
/// [`crush_array`](Self::crush_array), but hashing and stashing
/// `ccr_source` — not `items` — behind the row-drop marker.
///
/// The two differ on the prose-hook path: there, `items` are rows whose
/// leaves have ALREADY been rewritten (prose extractively compressed,
/// opaque blobs swapped for `<<ccr:…>>` markers). Storing those as the
/// entry's "original" hands a retrieving caller compressed output rather
/// than the dropped rows — the data the marker promises is simply not in
/// the store (#2694, same defect class as #1209). `ccr_source` is the
/// pre-processing array, so the marker's hash and the stored bytes both
/// describe what the model actually lost.
fn crush_array_with_source(
&self,
items: &[Value],
ccr_source: &[Value],
query_context: &str,
bias: f64,
) -> CrushArrayResult {
let item_strings: Vec<String> = items
.iter()
.map(|i| serde_json::to_string(i).unwrap_or_default())
@ -908,7 +933,9 @@ impl SmartCrusher {
// same bytes get stored — eliminating a redundant tree clone
// (`items.to_vec()`) and a redundant `serde_json::to_string`
// pass that the previous version did per dropped array.
let canonical = canonical_array_json(items);
// `ccr_source` == `items` except on the prose-hook path, where
// it is the pre-processing array — see `crush_array_with_source`.
let canonical = canonical_array_json(ccr_source);
let h = hash_canonical(&canonical);
let marker = format!("<<ccr:{h} {dropped_count}_rows_offloaded>>");
if let Some(store) = &self.ccr_store {

View file

@ -28,7 +28,10 @@ pub fn hash_field_name(field_name: &str) -> String {
let digest = hasher.finalize();
// Truncate to first 8 hex chars (4 bytes of digest). MUST match
// Python's `[:8]` — see module-level note above.
let hex = format!("{:x}", digest);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
hex[..8].to_string()
}

View file

@ -152,6 +152,175 @@ fn backend_swap_byte_equal_keys() {
}
}
// ─── Sliding (idle-window) TTL semantics — #2604 ───────────────────────
//
// The Python `CompressionStore` treats `HEADROOM_CCR_TTL_SECONDS` as an
// idle window that restarts on every successful retrieval, bounded by an
// absolute max lifetime (8x the idle TTL). These tests pin the same
// semantics onto the Rust backends so an entry a session keeps touching
// does not expire mid-burst.
#[test]
fn in_memory_get_refreshes_idle_ttl() {
let store = InMemoryCcrStore::with_capacity_and_ttl(10, Duration::from_millis(120));
let hash = compute_key(b"hot entry");
store.put(&hash, "hot entry");
// Touch the entry every 60ms for ~4 idle windows' worth of wall
// clock. Wall-clock expiry would kill it at 120ms; a sliding idle
// window keeps it alive because every hit restarts the clock.
for _ in 0..8 {
std::thread::sleep(Duration::from_millis(60));
assert_eq!(
store.get(&hash).as_deref(),
Some("hot entry"),
"an entry accessed within its idle window must stay alive"
);
}
// Now go idle past the window: the entry must expire.
std::thread::sleep(Duration::from_millis(200));
assert_eq!(
store.get(&hash),
None,
"an entry idle past its window must expire"
);
}
#[test]
fn in_memory_max_lifetime_caps_sliding_window() {
// Idle TTL 40ms → max lifetime 320ms (8x). Constant access must not
// keep the entry alive forever.
let store = InMemoryCcrStore::with_capacity_and_ttl(10, Duration::from_millis(40));
let hash = compute_key(b"immortal?");
store.put(&hash, "immortal?");
let deadline = std::time::Instant::now() + Duration::from_millis(600);
let mut expired = false;
while std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
if store.get(&hash).is_none() {
expired = true;
break;
}
}
assert!(
expired,
"constant access must not extend an entry past its max lifetime"
);
}
#[test]
fn sqlite_get_refreshes_idle_ttl() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ccr.sqlite");
// 3-second idle window (unix-second resolution needs whole seconds).
let store = SqliteCcrStore::open(&path, 3).expect("open sqlite store");
let hash = compute_key(b"sliding sqlite");
store.put(&hash, "sliding sqlite");
// t+2s: hit inside the window — restarts the idle clock.
std::thread::sleep(Duration::from_millis(2_000));
assert_eq!(
store.get(&hash).as_deref(),
Some("sliding sqlite"),
"first access within the idle window must hit"
);
// t+4s: wall-clock expiry would have purged at t+3s; the refresh at
// t+2s must keep it alive until t+5s.
std::thread::sleep(Duration::from_millis(2_000));
assert_eq!(
store.get(&hash).as_deref(),
Some("sliding sqlite"),
"an entry accessed within its idle window must stay alive past the wall-clock TTL"
);
// Go idle past the window.
std::thread::sleep(Duration::from_millis(4_100));
assert_eq!(
store.get(&hash),
None,
"an entry idle past its window must be purged"
);
}
#[test]
fn sqlite_max_lifetime_caps_sliding_window() {
// Timing note: the backend stores unix-SECONDS (`as_secs()` truncates)
// and purges on `last_accessed + ttl <= now`, so apparent elapsed time
// is `floor(t0 + s) - floor(t0)` — it rounds UP by nearly a second
// depending on where t0 lands within its second. Every margin here is
// therefore kept a full second clear of the boundary in both
// directions; a sub-second margin makes this test phase-dependent
// (the previous 1.5s-against-a-2s-window "still alive" assertion
// failed ~70% of runs whenever `frac(t0) >= 0.5`).
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ccr.sqlite");
// Idle 2s with a 3s ceiling: constant access must not outlive t+3s.
let store =
SqliteCcrStore::open_with_ttls(&path, 2, 3).expect("open sqlite store with ceiling");
let hash = compute_key(b"capped sqlite");
store.put(&hash, "capped sqlite");
// 0.5s: apparent elapsed is 0s or 1s — always under the 2s window.
std::thread::sleep(Duration::from_millis(500));
assert_eq!(
store.get(&hash).as_deref(),
Some("capped sqlite"),
"entry inside idle window and ceiling must hit"
);
// Keep touching, but cross the 3s ceiling. The touches must stay INSIDE
// the idle window or the entry dies of idleness and the assertion below
// passes without ever exercising the ceiling — the thing under test.
// 0.7s gaps read as at most 1s apparent, comfortably under the 2s idle
// window. Five gaps carry total age to at least 4s, which is strictly
// beyond the 3s ceiling even after unix-second truncation. Four gaps
// only reach 3.3s and can land exactly on the now-valid 3s boundary.
for _ in 0..5 {
std::thread::sleep(Duration::from_millis(700));
let _ = store.get(&hash);
}
assert_eq!(
store.get(&hash),
None,
"constant access must not extend an entry past its max lifetime"
);
}
#[test]
fn sqlite_migrates_legacy_schema_without_last_accessed() {
// A DB created by a pre-sliding-TTL build has no `last_accessed`
// column. Opening it must migrate in place and keep the rows
// retrievable (backfilling last_accessed from created_at).
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ccr.sqlite");
let payload = "legacy row";
let hash = compute_key(payload.as_bytes());
{
let conn = rusqlite::Connection::open(&path).expect("open raw connection");
conn.execute(
"CREATE TABLE ccr_entries (
hash TEXT PRIMARY KEY,
original BLOB NOT NULL,
created_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL
)",
[],
)
.expect("create legacy schema");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
conn.execute(
"INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![hash, payload.as_bytes(), now, 300_i64],
)
.expect("insert legacy row");
}
let store = SqliteCcrStore::open(&path, 300).expect("open must migrate legacy schema");
assert_eq!(
store.get(&hash).as_deref(),
Some(payload),
"legacy rows must survive the schema migration"
);
}
// ─── Redis-feature-gated tests ─────────────────────────────────────────
#[cfg(feature = "redis")]
@ -196,4 +365,31 @@ mod redis_tests {
store.put(&hash, payload);
assert_eq!(store.get(&hash).as_deref(), Some(payload));
}
#[test]
fn redis_get_refreshes_idle_ttl() {
let Some(url) = redis_url() else {
eprintln!("skipping redis_get_refreshes_idle_ttl: HEADROOM_TEST_REDIS_URL not set");
return;
};
// 2-second idle window (Redis EXPIRE has 1s resolution).
let store = RedisCcrStore::open_with_prefix(&url, "ccr_test_sliding".to_string(), 2)
.expect("open redis store");
let payload = "sliding redis";
let hash = compute_key(payload.as_bytes());
store.put(&hash, payload);
// Touch at t+1.5s (inside window) — restarts the idle clock.
std::thread::sleep(Duration::from_millis(1_500));
assert_eq!(store.get(&hash).as_deref(), Some(payload));
// t+3s: wall-clock expiry would have fired at t+2s.
std::thread::sleep(Duration::from_millis(1_500));
assert_eq!(
store.get(&hash).as_deref(),
Some(payload),
"an entry accessed within its idle window must stay alive past the wall-clock TTL"
);
// Go idle past the window.
std::thread::sleep(Duration::from_millis(3_100));
assert_eq!(store.get(&hash), None);
}
}

View file

@ -263,6 +263,7 @@ fn diff_tool_result_routes_to_diff_compressor() {
#[test]
fn source_code_tool_result_routes_to_code_compressor() {
headroom_core::transforms::set_code_compressor_enabled(true);
// Detector classifies this as SourceCode; the dispatcher now routes it
// to the Rust CodeCompressor (was no-op before the port landed). Twenty
// identical multi-line Rust functions are well above the SourceCode byte

View file

@ -23,7 +23,7 @@ tower-http = { version = "0.7", features = ["trace", "request-id", "util"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["json", "env-filter", "fmt"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "http2"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
clap = { workspace = true, features = ["derive", "env"] }
serde = { workspace = true }
serde_json = { workspace = true }
@ -38,7 +38,7 @@ http-body-util = "0.1"
hyper = "1"
url = "2"
humantime = "2"
bytesize = "1"
bytesize = "2"
tokio-util = { version = "0.7" }
headroom-core = { path = "../headroom-core" }
# Phase D PR-D1: native Bedrock InvokeModel route. SigV4 + AWS
@ -74,7 +74,7 @@ prometheus = { version = "=0.14.0", default-features = false }
# `aws-smithy-runtime-api`); promoted here to a direct, normal-build
# dependency so the drift detector compiles outside `cfg(test)`. Also
# used by PR-E4 for `prompt_cache_key` derivation.
sha2 = "0.10"
sha2 = "0.11"
# PR-E6: bounded session-scoped cache of structural hashes. The
# detector evicts the oldest session at 1000 entries — we never want
# unbounded memory growth from a flood of unique session keys. `lru`
@ -98,7 +98,7 @@ md-5 = "0.10"
tower = { workspace = true, features = ["util"] }
wiremock = "0.6"
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "http2", "json"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
futures-util = "0.3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "net", "io-util", "time", "test-util", "process"] }
hyper = { version = "1", features = ["server", "http1", "http2"] }
@ -110,7 +110,7 @@ tokio-stream = "0.1"
# way to gate "the proxy did not perturb the request" because JSON
# value-equality misses whitespace, key order, and Unicode escape
# differences that all bust the prompt cache.
sha2 = "0.10"
sha2 = "0.11"
# PR-C1: property tests for the byte-level SSE parser. The parser
# must never panic on arbitrary input bytes (TCP can hand us anything,
# including malformed UTF-8 split mid-codepoint or fuzz-generated

View file

@ -615,6 +615,7 @@ mod tests {
client: reqwest::Client::new(),
bedrock_credentials: None,
drift_state: crate::cache_stabilization::drift_detector::DriftState::new(8),
beta_sticky: crate::cache_stabilization::beta_sticky::BetaStickyState::new(8),
vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new(
"test".to_string(),
)),
@ -649,6 +650,7 @@ mod tests {
// unit test never observes drift, but `AppState` requires
// the field to be populated.
drift_state: crate::cache_stabilization::drift_detector::DriftState::new(8),
beta_sticky: crate::cache_stabilization::beta_sticky::BetaStickyState::new(8),
// PR-D4: unit tests for the Bedrock URL builder don't
// touch the Vertex route, but `AppState` is one struct
// — supply a dummy token source so the test compiles.
@ -684,6 +686,7 @@ mod tests {
// PR-E6: see above — drift detector is unused by this
// test; we just satisfy the struct shape.
drift_state: crate::cache_stabilization::drift_detector::DriftState::new(8),
beta_sticky: crate::cache_stabilization::beta_sticky::BetaStickyState::new(8),
// PR-D4: unit tests for the Bedrock URL builder don't
// touch the Vertex route, but `AppState` is one struct
// — supply a dummy token source so the test compiles.

View file

@ -1014,6 +1014,7 @@ mod tests {
// PR-E6: drift detector is unused by this URL-builder
// unit test; small capacity to satisfy the struct shape.
drift_state: crate::cache_stabilization::drift_detector::DriftState::new(8),
beta_sticky: crate::cache_stabilization::beta_sticky::BetaStickyState::new(8),
// PR-D4: unit tests for the Bedrock URL builder don't
// touch the Vertex route, but `AppState` is one struct
// — supply a dummy token source so the test compiles.
@ -1056,6 +1057,7 @@ mod tests {
client: reqwest::Client::new(),
bedrock_credentials: None,
drift_state: crate::cache_stabilization::drift_detector::DriftState::new(8),
beta_sticky: crate::cache_stabilization::beta_sticky::BetaStickyState::new(8),
vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new(
"test".to_string(),
)),

View file

@ -0,0 +1,620 @@
//! Session-sticky provider beta headers — Rust port of the Python
//! proxy's `SessionBetaTracker` (PR-A6, `headroom/proxy/helpers.py`).
//!
//! ## Why
//!
//! Provider beta headers (`anthropic-beta`, `openai-beta`) are part of
//! the request bytes that determine the upstream prefix-cache key.
//! Interactive clients (Claude Code, Codex CLI) MAY drop a beta token
//! between turn N and turn N+1 of the same conversation; the cache hot
//! zone is positional, so the next turn's prefix hashes differently and
//! the prefix-cache read misses — the customer silently pays for a full
//! prompt re-write. The Python proxy defeats this with a bounded LRU
//! tracker that unions the client's tokens with every token previously
//! seen for the same `(provider, session)` and forwards the union.
//!
//! The Rust proxy replaces the Python request path in Phase H, which
//! deletes `SessionBetaTracker` with the rest of
//! `headroom/proxy/helpers.py`. Without this port the protection —
//! and its documented operator contract
//! (`docs/content/docs/configuration.mdx`, "Session Beta Header
//! Tracking") — would silently not survive the migration.
//!
//! ## Behaviour contract (parity with Python)
//!
//! - Union client tokens with previously-seen tokens for the session,
//! preserving first-seen order; case-insensitive dedup where the
//! first-seen casing wins.
//! - Keyed by `(provider, session)` so the same session id against
//! Anthropic and OpenAI upstreams keeps independent token sets.
//! - Bounded LRU (`BETA_TRACKER_CAPACITY` sessions): lookups touch
//! recency, overflow evicts the oldest session.
//! - The tracker only ever records tokens the client itself sent.
//! Headroom-added tokens (e.g. memory-tool betas on the Python
//! path) are NOT recorded — the forwarded union is always a subset
//! of values this client already put on the wire, which is what
//! keeps the mechanism consistent with the subscription-stealth
//! invariant (REALIGNMENT invariant #10: "no beta drift").
//!
//! The operator opt-out lives at the call site: when
//! `Config::beta_header_sticky` is `disabled` the proxy skips the
//! tracker entirely and forwards the client header verbatim (the
//! Python proxy's `HEADROOM_BETA_HEADER_STICKY=disabled` diagnostic
//! mode). That gate is per REALIGNMENT build constraint #4 an explicit
//! loud opt-in, not a silent fallback.
//!
//! Session identity comes from
//! [`super::drift_detector::derive_session_key`] — the same
//! conversation-aware key the drift detector uses (explicit
//! `x-headroom-session-id` when the client declares it, otherwise
//! credential/IP arms folded with a first-message conversation
//! discriminator).
//!
//! ## Divergence from Python: per-conversation, not per-(model, system)
//!
//! The Python tracker keys on the store session id — explicit header,
//! else a hash of `(model, leading system prompt)` — so all parallel
//! conversations sharing a model + system prompt (a Claude Code
//! session and every one of its subagents) share ONE token union and
//! cross-inherit each other's tokens. This port keys on the drift
//! detector's conversation-aware key instead, so each conversation
//! keeps its own union; the integration test
//! `separate_conversations_do_not_leak_tokens` pins that. Deliberate:
//! the `(model, system)` bucket conflating parallel agentic
//! conversations is the exact defect #2085 / #2193 / #2301 chased out
//! of the other session-sticky subsystems. The cost is losing
//! Python's accidental cross-conversation repair (conversation B
//! turn 1 inheriting a token only conversation A ever sent); each
//! conversation's stickiness now starts from its own first sighting,
//! which is also the only variant that can't leak one tenant-visible
//! experiment token into an unrelated conversation's request bytes.
use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use http::header::{HeaderMap, HeaderValue};
use lru::LruCache;
use super::drift_detector::session_key_log_prefix;
/// Maximum number of `(provider, session)` entries tracked. Sessions
/// are keyed per conversation (see module docs), so the working set is
/// the number of concurrently active conversations — same sizing
/// rationale as the drift detector's capacity. Eviction cost is
/// re-learning a live session's dropped tokens from scratch (the next
/// turn forwards the client value verbatim), not a lost request.
pub const BETA_TRACKER_CAPACITY: usize = 1000;
/// Upstream namespace for a tracked beta-token set. Mirrors the
/// Python tracker's `provider` string key ("anthropic" / "openai").
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BetaProvider {
/// `/v1/messages` — `anthropic-beta` header.
Anthropic,
/// `/v1/chat/completions` and `/v1/responses` — `openai-beta`
/// header. One namespace for both endpoints, matching the Python
/// proxy's single `provider="openai"` key.
OpenAi,
}
impl BetaProvider {
/// Stable lower-case label for log fields; matches the Python
/// tracker's provider strings.
pub fn as_str(self) -> &'static str {
match self {
BetaProvider::Anthropic => "anthropic",
BetaProvider::OpenAi => "openai",
}
}
/// The request header this provider's beta tokens travel in.
pub fn header_name(self) -> &'static str {
match self {
BetaProvider::Anthropic => "anthropic-beta",
BetaProvider::OpenAi => "openai-beta",
}
}
}
/// Split a comma-separated beta-header value into trimmed, non-empty
/// tokens. Port of the Python `split_beta_tokens` helper.
pub fn split_beta_tokens(value: Option<&str>) -> Vec<String> {
value
.unwrap_or("")
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// Per-session ordered token lists, keyed by `(provider, session)`.
type SessionTokenCache = LruCache<(BetaProvider, String), Vec<String>>;
/// Bounded LRU of beta tokens observed per `(provider, session)`.
///
/// Cloning shares the underlying map (`Arc`), mirroring
/// [`super::drift_detector::DriftState`] so one instance lives in
/// `AppState` and clones freely into every handler path.
#[derive(Clone)]
pub struct BetaStickyState {
sessions: Arc<Mutex<SessionTokenCache>>,
}
impl BetaStickyState {
/// Create a tracker bounded to `capacity` sessions.
///
/// # Panics
///
/// Panics when `capacity == 0`, mirroring `DriftState::new` (the
/// Python tracker raises `ValueError` on a non-positive bound).
pub fn new(capacity: usize) -> Self {
let cap = NonZeroUsize::new(capacity).expect("BetaStickyState capacity must be > 0");
Self {
sessions: Arc::new(Mutex::new(LruCache::new(cap))),
}
}
/// Union `client_value`'s tokens with the session's previously
/// seen tokens, update the session, and return the merged
/// comma-separated value (possibly empty). Port of the Python
/// `SessionBetaTracker.record_and_get_sticky_betas`.
///
/// On a poisoned lock the tracker fails open: the client value is
/// returned verbatim (trimmed) and state is left untouched —
/// never drop or delay the request for a telemetry-adjacent
/// protection.
pub fn record_and_get_sticky_betas(
&self,
provider: BetaProvider,
session_key: &str,
client_value: Option<&str>,
) -> String {
let client_tokens = split_beta_tokens(client_value);
let mut sessions = match self.sessions.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!(
event = "beta_sticky_lock_poisoned",
provider = provider.as_str(),
"beta tracker lock poisoned; forwarding client value verbatim"
);
drop(poisoned);
return client_tokens.join(",");
}
};
let key = (provider, session_key.to_string());
// `get_mut` touches LRU recency on hit, mirroring the Python
// tracker's move-to-end.
if let Some(merged) = sessions.get_mut(&key) {
// Dedup is case-insensitive with the first-seen casing
// winning. Header values reaching this point are visible
// ASCII (`HeaderValue::to_str` rejects anything else), so
// ASCII lowercasing matches Python's `str.lower()` over
// the reachable domain.
let mut seen: HashSet<String> = merged.iter().map(|t| t.to_ascii_lowercase()).collect();
for token in client_tokens {
if seen.insert(token.to_ascii_lowercase()) {
merged.push(token);
}
}
return merged.join(",");
}
let mut merged: Vec<String> = Vec::with_capacity(client_tokens.len());
let mut seen: HashSet<String> = HashSet::with_capacity(client_tokens.len());
for token in client_tokens {
if seen.insert(token.to_ascii_lowercase()) {
merged.push(token);
}
}
let joined = merged.join(",");
// `put` on a fresh key evicts the oldest entry once the cache
// is at capacity — the Python tracker's bounded-LRU overflow
// pop. Sessions that never sent a beta token still occupy a
// slot (Python stores their empty list too); the cost is one
// LRU entry, the benefit is identical recency behaviour.
sessions.put(key, merged);
joined
}
/// Number of tracked sessions (test observability).
#[cfg(test)]
fn active_sessions(&self) -> usize {
self.sessions.lock().map(|c| c.len()).unwrap_or(0)
}
}
/// Count tokens in a raw header value without allocating a `Vec`
/// (log-field helper; same tokenization as [`split_beta_tokens`]).
fn count_beta_tokens(value: Option<&str>) -> usize {
value
.unwrap_or("")
.split(',')
.filter(|t| !t.trim().is_empty())
.count()
}
/// Record the client's beta header for this `(provider, session)` and
/// rewrite the upstream-bound header to the session union when they
/// differ. The full merge site: reads `provider.header_name()` from
/// `outgoing_headers`, unions via the tracker, mutates the map in
/// place. Mirrors the Python handler block (anthropic.py PR-A6):
/// rewrite only when the union is non-empty and differs from the
/// client value; an absent client header gains the union; a session
/// with no tokens anywhere stays header-less.
///
/// Fail-open contract: a client value that isn't visible ASCII is
/// forwarded verbatim and nothing is recorded (never rewrite what we
/// can't faithfully parse); an unencodable union (unreachable — every
/// token came from a parsed header value) logs and forwards verbatim.
///
/// Logging: counts only — beta tokens can carry experiment IDs the
/// user hasn't opted to share with Headroom logs (Python
/// `log_beta_header_merge` contract). Python logs every merge at
/// info; here the no-op case drops to debug, matching the drift
/// detector's silent-on-stable precedent, so an info-level
/// `beta_header_merge` always marks an actual cache-affecting
/// rewrite.
pub fn apply_sticky_betas(
tracker: &BetaStickyState,
provider: BetaProvider,
session_key: &str,
outgoing_headers: &mut HeaderMap,
request_id: &str,
) {
let header_name = provider.header_name();
// Join repeated field lines with "," per RFC 9110 §5.3 list
// semantics BEFORE recording, so a client sending two beta lines
// has both recorded and a later rewrite (which `insert`s a single
// line, dropping the others) can never shrink the upstream token
// set mid-conversation.
let mut parts: Vec<&str> = Vec::new();
for raw in outgoing_headers.get_all(header_name) {
match raw.to_str() {
Ok(s) => parts.push(s),
Err(_) => {
tracing::debug!(
event = "beta_header_merge_skipped",
request_id = %request_id,
provider = provider.as_str(),
reason = "non_ascii_header_value",
"client beta header is not visible ASCII; forwarding verbatim"
);
return;
}
}
}
let client_value: Option<String> = if parts.is_empty() {
None
} else {
Some(parts.join(","))
};
let sticky =
tracker.record_and_get_sticky_betas(provider, session_key, client_value.as_deref());
let rewritten = !sticky.is_empty() && sticky != client_value.as_deref().unwrap_or("");
if rewritten {
match HeaderValue::from_str(&sticky) {
Ok(value) => {
outgoing_headers.insert(header_name, value);
}
Err(error) => {
tracing::warn!(
event = "beta_header_merge_skipped",
request_id = %request_id,
provider = provider.as_str(),
reason = "unencodable_union",
error = %error,
"sticky beta union not encodable as a header value"
);
return;
}
}
}
let client_betas = count_beta_tokens(client_value.as_deref());
let sticky_betas = count_beta_tokens(Some(&sticky));
if rewritten {
tracing::info!(
event = "beta_header_merge",
request_id = %request_id,
provider = provider.as_str(),
session_key_hash = %session_key_log_prefix(session_key),
client_betas,
sticky_betas,
"session-sticky beta merge rewrote the upstream header"
);
} else {
tracing::debug!(
event = "beta_header_merge",
request_id = %request_id,
provider = provider.as_str(),
session_key_hash = %session_key_log_prefix(session_key),
client_betas,
sticky_betas,
"session-sticky beta merge (no-op)"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
// -----------------------------------------------------------------
// split_beta_tokens — port of the Python tokenizer contract.
// -----------------------------------------------------------------
#[test]
fn split_none_and_empty_yield_no_tokens() {
assert!(split_beta_tokens(None).is_empty());
assert!(split_beta_tokens(Some("")).is_empty());
assert!(split_beta_tokens(Some(" ")).is_empty());
assert!(split_beta_tokens(Some(",, ,")).is_empty());
}
#[test]
fn split_trims_and_drops_empty_segments() {
assert_eq!(
split_beta_tokens(Some(" a , ,b, c-1 ")),
vec!["a".to_string(), "b".to_string(), "c-1".to_string()]
);
}
// -----------------------------------------------------------------
// record_and_get_sticky_betas — tracker semantics ported from
// tests/test_anthropic_beta_session_sticky.py.
// -----------------------------------------------------------------
fn tracker() -> BetaStickyState {
BetaStickyState::new(BETA_TRACKER_CAPACITY)
}
#[test]
fn first_request_returns_client_tokens() {
let t = tracker();
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a,b"));
assert_eq!(got, "a,b");
}
#[test]
fn dropped_token_is_reinjected_on_next_turn() {
// The cache-killer this module exists for: turn N sends
// "a,b", turn N+1 drops "b" — the union must restore it.
let t = tracker();
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a,b"));
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a"));
assert_eq!(got, "a,b");
}
#[test]
fn union_preserves_first_seen_order() {
let t = tracker();
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("b,a"));
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a,c"));
assert_eq!(got, "b,a,c");
}
#[test]
fn dedup_is_case_insensitive_first_casing_wins() {
let t = tracker();
t.record_and_get_sticky_betas(
BetaProvider::Anthropic,
"s1",
Some("Context-Management-2025-06-27"),
);
let got = t.record_and_get_sticky_betas(
BetaProvider::Anthropic,
"s1",
Some("context-management-2025-06-27"),
);
assert_eq!(got, "Context-Management-2025-06-27");
}
#[test]
fn duplicate_client_tokens_are_deduped() {
let t = tracker();
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a,a,b,A"));
assert_eq!(got, "a,b");
}
#[test]
fn client_whitespace_is_trimmed_in_union() {
let t = tracker();
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some(" a , b "));
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("c "));
assert_eq!(got, "a,b,c");
}
#[test]
fn absent_client_value_returns_session_union() {
let t = tracker();
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a"));
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", None);
assert_eq!(got, "a");
}
#[test]
fn empty_session_and_client_yield_empty_string() {
let t = tracker();
assert_eq!(
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", None),
""
);
}
#[test]
fn providers_keep_independent_namespaces() {
// Same session id, different providers — token sets must not
// leak across (Python: the (provider, session_id) tuple key).
let t = tracker();
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("anth-only"));
let got = t.record_and_get_sticky_betas(BetaProvider::OpenAi, "s1", Some("oai-only"));
assert_eq!(got, "oai-only");
}
#[test]
fn sessions_keep_independent_token_sets() {
let t = tracker();
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a"));
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s2", Some("b"));
assert_eq!(got, "b");
}
#[test]
fn lru_evicts_oldest_session_at_capacity() {
let t = BetaStickyState::new(2);
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a"));
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s2", Some("b"));
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s3", Some("c"));
assert_eq!(t.active_sessions(), 2);
// s1 was evicted: its history is gone, so a bare re-request
// returns only the fresh client value.
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("z"));
assert_eq!(got, "z");
}
#[test]
fn lru_hit_touches_recency() {
let t = BetaStickyState::new(2);
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("a"));
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s2", Some("b"));
// Touch s1 so s2 becomes the eviction candidate.
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", None);
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s3", Some("c"));
// s1 survived the s3 insert…
assert_eq!(
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", None),
"a"
);
// …and s2 did not.
assert_eq!(
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s2", None),
""
);
}
// -----------------------------------------------------------------
// apply_sticky_betas — header-map plumbing.
// -----------------------------------------------------------------
fn header_map(values: &[&str]) -> HeaderMap {
let mut map = HeaderMap::new();
for v in values {
map.append("anthropic-beta", HeaderValue::from_str(v).unwrap());
}
map
}
fn beta_values(map: &HeaderMap) -> Vec<String> {
map.get_all("anthropic-beta")
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect()
}
#[test]
fn apply_rewrites_dropped_token_to_union() {
let t = tracker();
let mut turn1 = header_map(&["a,b"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut turn1, "req-1");
assert_eq!(beta_values(&turn1), vec!["a,b"]);
let mut turn2 = header_map(&["a"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut turn2, "req-2");
assert_eq!(beta_values(&turn2), vec!["a,b"]);
}
#[test]
fn apply_reinserts_union_when_header_fully_omitted() {
let t = tracker();
let mut turn1 = header_map(&["a,b"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut turn1, "req-1");
let mut turn2 = HeaderMap::new();
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut turn2, "req-2");
assert_eq!(beta_values(&turn2), vec!["a,b"]);
}
#[test]
fn apply_never_invents_a_header() {
let t = tracker();
let mut map = HeaderMap::new();
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut map, "req-1");
assert!(map.get("anthropic-beta").is_none());
}
#[test]
fn apply_noop_leaves_header_lines_untouched() {
let t = tracker();
let mut map = header_map(&["a,b"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut map, "req-1");
let mut again = header_map(&["a,b"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut again, "req-2");
assert_eq!(beta_values(&again), vec!["a,b"]);
}
#[test]
fn apply_records_all_repeated_header_lines() {
// RFC 9110 list semantics: two field lines are one list. The
// union must record BOTH lines, so a later rewrite (which
// collapses to a single line) can never shrink the upstream
// token set mid-conversation.
let t = tracker();
let mut turn1 = header_map(&["a,x", "b"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut turn1, "req-1");
// No rewrite on turn 1 (union == joined client list): both
// lines pass through untouched.
assert_eq!(beta_values(&turn1), vec!["a,x", "b"]);
// Turn 2 drops "x" from the first line: the rewrite must
// carry the full set from both turn-1 lines.
let mut turn2 = header_map(&["a", "b"]);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut turn2, "req-2");
assert_eq!(beta_values(&turn2), vec!["a,x,b"]);
}
#[test]
fn apply_skips_non_ascii_value_and_records_nothing() {
let t = tracker();
let mut map = HeaderMap::new();
map.insert(
"anthropic-beta",
HeaderValue::from_bytes(&[0xfa, 0xfb]).unwrap(),
);
apply_sticky_betas(&t, BetaProvider::Anthropic, "s1", &mut map, "req-1");
// Wire bytes untouched…
assert_eq!(map.get("anthropic-beta").unwrap().as_bytes(), &[0xfa, 0xfb]);
// …and nothing recorded: the next ASCII turn sees only its
// own tokens.
let got = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some("y"));
assert_eq!(got, "y");
}
#[test]
fn concurrent_unions_lose_no_tokens() {
// Port of the Python thread-hammering test: concurrent turns
// on one session must never drop a recorded token.
let t = tracker();
std::thread::scope(|s| {
for i in 0..8 {
let t = t.clone();
s.spawn(move || {
let token = format!("tok-{i}");
for _ in 0..50 {
t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", Some(&token));
}
});
}
});
let merged = t.record_and_get_sticky_betas(BetaProvider::Anthropic, "s1", None);
let tokens: HashSet<&str> = merged.split(',').collect();
for i in 0..8 {
assert!(tokens.contains(format!("tok-{i}").as_str()));
}
}
}

View file

@ -399,8 +399,9 @@ pub fn observe_drift(state: &DriftState, session_key: &str, current: StructuralH
/// 16-char hex prefix of SHA-256(session_key). Bounds the log line
/// width and never reveals the raw key (which may be a bearer token
/// or API key — see `derive_session_key`).
fn session_key_log_prefix(session_key: &str) -> String {
/// or API key — see `derive_session_key`). `pub(crate)` so the
/// beta-sticky merge site logs the same session identity the same way.
pub(crate) fn session_key_log_prefix(session_key: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(session_key.as_bytes());
let digest = hasher.finalize();

View file

@ -12,9 +12,14 @@
//! - **Normalize** request bytes to make cache hits deterministic
//! under PAYG mode ([`tool_def_normalize`], PR-E1 / PR-E2;
//! [`anthropic_cache_control`], PR-E3; [`openai_cache_key`], PR-E4).
//! These mutate bytes only when the auth-mode gate and per-policy
//! preconditions (e.g. no customer `cache_control` marker) all clear;
//! OAuth and Subscription always passthrough.
//! These mutate *body* bytes only when the auth-mode gate and
//! per-policy preconditions (e.g. no customer `cache_control`
//! marker) all clear; for body mutations, OAuth and Subscription
//! always passthrough.
//! - **Re-echo** client-sent state ([`beta_sticky`]): mutate request
//! *headers* only, on every auth mode, and only ever with values
//! the same client already put on the wire — anti-drift repair of
//! the client's own signal, never injection of Headroom state.
//!
//! Currently shipped:
//!
@ -49,6 +54,16 @@
//! `(model, system, tools)` and inject it so the upstream pins
//! cache lookup to a tenant-stable identity. **Mutates the body**
//! (only on PAYG) — see its docs for the gating contract.
//! - [`beta_sticky`] — parity port of the Python proxy's PR-A6
//! `SessionBetaTracker`: per-`(provider, session)` LRU that unions
//! `anthropic-beta` / `openai-beta` tokens across turns so a client
//! dropping a token mid-conversation doesn't rotate the upstream
//! prefix-cache key. **Mutates request headers, never the body**;
//! applies to all auth modes exactly like the Python path (the
//! union only ever contains tokens this client itself sent, so
//! subscription stealth — invariant #10 "no beta drift" — is
//! preserved by construction). Operator opt-out:
//! `--beta-header-sticky disabled`.
//!
//! Sibling PRs hang additional submodules off this `mod.rs`. Conflict
//! resolution between parallel Phase E PRs is intentionally trivial:
@ -56,6 +71,7 @@
//! `mod.rs`'s `pub mod` list.
pub mod anthropic_cache_control;
pub mod beta_sticky;
pub mod drift_detector;
pub mod openai_cache_key;
pub mod tool_def_normalize;

View file

@ -1,6 +1,9 @@
//! Configuration for the proxy: CLI flags + env vars.
use clap::{Parser, ValueEnum};
use headroom_core::rollout::{
feature_names, split_feature_names, Feature, RolloutChannel, RolloutSnapshot,
};
use std::net::SocketAddr;
use std::time::Duration;
use url::Url;
@ -181,6 +184,48 @@ impl CompressionMode {
}
}
/// Session-sticky provider beta headers (parity port of the Python
/// proxy's `HEADROOM_BETA_HEADER_STICKY`; see
/// `cache_stabilization::beta_sticky`).
///
/// When `enabled` (default), the proxy unions each request's
/// `anthropic-beta` / `openai-beta` tokens with the tokens previously
/// seen for the same conversation and forwards the union, so a client
/// dropping a beta token mid-conversation doesn't rotate the upstream
/// prefix-cache key.
///
/// When `disabled`, the client header is forwarded verbatim and no
/// per-session token state is kept. Diagnostic operator opt-in — NOT
/// a fallback per realignment build constraint #4.
///
/// Source priority: CLI flag → `HEADROOM_PROXY_BETA_HEADER_STICKY`
/// env var → default (`enabled`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "snake_case")]
pub enum BetaHeaderSticky {
/// Union beta tokens per conversation and forward the union.
/// Default. Matches the Python proxy's default behaviour.
Enabled,
/// Forward the client's beta header verbatim; keep no state.
/// Diagnostic-only.
Disabled,
}
impl BetaHeaderSticky {
/// Stable snake_case name suitable for log fields.
pub fn as_str(self) -> &'static str {
match self {
BetaHeaderSticky::Enabled => "enabled",
BetaHeaderSticky::Disabled => "disabled",
}
}
/// Convenience: is the sticky union switched on?
pub fn is_enabled(self) -> bool {
matches!(self, BetaHeaderSticky::Enabled)
}
}
#[derive(Debug, Clone, Parser)]
#[command(
name = "headroom-proxy",
@ -188,6 +233,49 @@ impl CompressionMode {
about = "Headroom transparent reverse proxy"
)]
pub struct CliArgs {
/// Runtime rollout channel that bounds which managed features may run.
///
/// `stable` admits only features that have completed bake time. `beta` and
/// `canary` admit progressively newer features. `dev` is for local work.
/// Explicit feature requests still cannot cross this boundary unless the
/// unsafe override is set.
#[arg(
long = "rollout-channel",
env = "HEADROOM_ROLLOUT_CHANNEL",
default_value = "stable",
value_parser = parse_rollout_channel,
)]
pub rollout_channel: String,
/// Comma-separated rollout features to request explicitly.
#[arg(
long = "features",
env = "HEADROOM_FEATURES",
default_value = "",
value_parser = parse_rollout_features,
)]
pub features: String,
/// Comma-separated rollout features to force off. Disable wins over defaults
/// and explicit enable requests.
#[arg(
long = "disable-features",
env = "HEADROOM_DISABLE_FEATURES",
default_value = "",
value_parser = parse_rollout_features,
)]
pub disable_features: String,
/// Break-glass override that allows unstable features below their channel.
/// Intended only for emergency mitigation and should be visible in logs.
#[arg(
long = "unsafe-allow-unstable-features",
env = "HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES",
default_value_t = false,
action = clap::ArgAction::Set,
)]
pub unsafe_allow_unstable_features: bool,
/// Address the proxy listens on (e.g. 0.0.0.0:8787).
#[arg(long, env = "HEADROOM_PROXY_LISTEN", default_value = "0.0.0.0:8787")]
pub listen: SocketAddr,
@ -320,6 +408,28 @@ pub struct CliArgs {
)]
pub strip_internal_headers: StripInternalHeaders,
/// Session-sticky provider beta headers: union `anthropic-beta` /
/// `openai-beta` tokens per conversation so a client dropping a
/// token mid-conversation doesn't bust the upstream prefix cache.
/// Parity port of the Python proxy's `SessionBetaTracker` (PR-A6).
/// Default `enabled`; `disabled` is a diagnostic operator opt-in.
///
/// Active only when the compression interceptor is on
/// (`--compression` / `HEADROOM_PROXY_COMPRESSION=1`): with the
/// interceptor off the proxy is a strict byte-pipe and never
/// mutates headers. Startup logs a warning when this is `enabled`
/// while `--compression` is off.
///
/// Source priority: CLI flag → `HEADROOM_PROXY_BETA_HEADER_STICKY`
/// env var → default (`enabled`).
#[arg(
long = "beta-header-sticky",
env = "HEADROOM_PROXY_BETA_HEADER_STICKY",
value_enum,
default_value_t = BetaHeaderSticky::Enabled,
)]
pub beta_header_sticky: BetaHeaderSticky,
/// Phase C PR-C4: enable the `/v1/responses` SSE streaming
/// pipeline. When `true` (default), `Accept: text/event-stream`
/// requests on `/v1/responses` flow through the byte-level SSE
@ -387,7 +497,7 @@ pub struct CliArgs {
/// Enable the Kompress ML prose compressor for `PlainText` blocks in the
/// live zone. Default `false`: Kompress loads a ~261 MB ONNX model
/// (resolved CACHE-ONLY — the proxy never downloads it), so — unlike the
/// always-on structural compressors and the AST CodeCompressor — operators
/// structural compressors and the separately gated AST CodeCompressor — operators
/// opt in. When `false`, plain-text blocks pass through untouched and the
/// model is never loaded. Mirrors the Python reference's `enable_kompress`.
///
@ -401,6 +511,17 @@ pub struct CliArgs {
)]
pub enable_kompress: bool,
/// Enable AST-aware compression for `SourceCode` live-zone blocks.
/// Default `false` so upgrading the proxy does not change output unless
/// an operator explicitly opts in.
#[arg(
long = "enable-code-compressor",
env = "HEADROOM_PROXY_ENABLE_CODE_COMPRESSOR",
default_value_t = false,
action = clap::ArgAction::Set,
)]
pub enable_code_compressor: bool,
/// AWS region to use when signing Bedrock requests. Default
/// `us-east-1`. The Bedrock endpoint URL derived from this
/// region is `https://bedrock-runtime.{region}.amazonaws.com`
@ -492,6 +613,32 @@ fn parse_duration(s: &str) -> Result<Duration, String> {
humantime::parse_duration(s).map_err(|e| format!("invalid duration `{s}`: {e}"))
}
fn parse_rollout_channel(value: &str) -> Result<String, String> {
value
.parse::<RolloutChannel>()
.map(|channel| channel.as_str().to_owned())
.map_err(|_| {
format!("unknown rollout channel `{value}` (valid: stable, beta, canary, dev)")
})
}
fn parse_rollout_features(value: &str) -> Result<String, String> {
let valid = feature_names();
let unknown: Vec<_> = split_feature_names(value)
.into_iter()
.filter(|name| !valid.contains(name.as_str()))
.collect();
if unknown.is_empty() {
Ok(value.to_owned())
} else {
Err(format!(
"unknown rollout feature(s): {}; valid: {}",
unknown.join(", "),
valid.into_iter().collect::<Vec<_>>().join(", ")
))
}
}
fn parse_bytes(s: &str) -> Result<u64, String> {
s.parse::<bytesize::ByteSize>()
.map(|b| b.as_u64())
@ -501,6 +648,8 @@ fn parse_bytes(s: &str) -> Result<u64, String> {
/// Resolved configuration used by the running server.
#[derive(Debug, Clone)]
pub struct Config {
/// Runtime rollout state resolved from CLI/env.
pub rollout: RolloutSnapshot,
pub listen: SocketAddr,
pub upstream: Url,
pub upstream_timeout: Duration,
@ -534,6 +683,9 @@ pub struct Config {
/// upstream-bound requests. PR-A5 default-on guard against
/// fingerprinting / leakage of internal flags.
pub strip_internal_headers: StripInternalHeaders,
/// Session-sticky provider beta headers (parity port of the
/// Python `SessionBetaTracker`, PR-A6). Default `enabled`.
pub beta_header_sticky: BetaHeaderSticky,
/// PR-C4: enable the `/v1/responses` streaming pipeline (SSE
/// state-machine + telemetry tee). Default `true`.
pub enable_responses_streaming: bool,
@ -552,6 +704,9 @@ pub struct Config {
/// blocks. Default `false` — it loads a ~261 MB cache-only model, so
/// operators opt in. Mirrors the Python reference's `enable_kompress`.
pub enable_kompress: bool,
/// Enable AST-aware compression for `SourceCode` live-zone blocks.
/// Default `false`; operators opt in explicitly.
pub enable_code_compressor: bool,
/// PR-D1: AWS region used to sign Bedrock requests + (when no
/// explicit endpoint is set) derive the Bedrock endpoint URL.
pub bedrock_region: String,
@ -576,6 +731,30 @@ pub struct Config {
impl Config {
pub fn from_cli(args: CliArgs) -> Self {
let mut explicit_features = Vec::new();
if args.enable_responses_streaming {
explicit_features.push(Feature::OpenAiResponsesStreaming);
}
if args.enable_bedrock_native {
explicit_features.push(Feature::NativeBedrock);
}
// Preserve the pre-rollout rollback controls as legacy disables. Both
// features are stable defaults in the registry, so merely omitting a
// false flag from `explicit_features` would turn it straight back on.
let mut disabled_features = split_feature_names(&args.disable_features);
if !args.enable_responses_streaming {
disabled_features.push(Feature::OpenAiResponsesStreaming.spec().name.to_owned());
}
if !args.enable_bedrock_native {
disabled_features.push(Feature::NativeBedrock.spec().name.to_owned());
}
let rollout = RolloutSnapshot::from_parts_with_explicit(
&args.rollout_channel,
&args.features,
&disabled_features.join(","),
args.unsafe_allow_unstable_features,
&explicit_features,
);
let rewrite_host = if args.no_rewrite_host {
false
} else {
@ -585,6 +764,7 @@ impl Config {
.compression_max_body_bytes
.unwrap_or(args.max_body_bytes);
Self {
rollout: rollout.clone(),
listen: args.listen,
upstream: args.upstream,
upstream_timeout: args.upstream_timeout,
@ -599,10 +779,16 @@ impl Config {
cache_control_auto_frozen: args.cache_control_auto_frozen,
auth_mode_policy_enforcement: args.auth_mode_policy_enforcement,
strip_internal_headers: args.strip_internal_headers,
enable_responses_streaming: args.enable_responses_streaming,
beta_header_sticky: args.beta_header_sticky,
enable_responses_streaming: rollout.is_enabled(
Feature::OpenAiResponsesStreaming,
args.enable_responses_streaming,
),
enable_conversations_passthrough: args.enable_conversations_passthrough,
enable_bedrock_native: args.enable_bedrock_native,
enable_bedrock_native: rollout
.is_enabled(Feature::NativeBedrock, args.enable_bedrock_native),
enable_kompress: args.enable_kompress,
enable_code_compressor: args.enable_code_compressor,
bedrock_region: args.bedrock_region,
bedrock_endpoint: args.bedrock_endpoint,
aws_profile: args.aws_profile,
@ -616,6 +802,7 @@ impl Config {
/// production-default behaviour so existing tests stay unchanged.
pub fn for_test(upstream: Url) -> Self {
Self {
rollout: RolloutSnapshot::default(),
listen: "127.0.0.1:0".parse().unwrap(),
upstream,
upstream_timeout: Duration::from_secs(60),
@ -643,6 +830,9 @@ impl Config {
// from upstream-bound requests. Tests opt out per-case via
// `start_proxy_with`.
strip_internal_headers: StripInternalHeaders::Enabled,
// Production default: sticky beta-header union per
// conversation (Python-parity). Tests opt out per-case.
beta_header_sticky: BetaHeaderSticky::Enabled,
// PR-C4: streaming pipeline + conversations passthrough
// both default-on so tests exercise the same paths
// production traffic will hit.
@ -657,6 +847,7 @@ impl Config {
// opt in to the ~261 MB model. Tests that exercise the PlainText
// path enable it explicitly via `set_kompress_enabled`.
enable_kompress: false,
enable_code_compressor: false,
bedrock_region: "us-east-1".to_string(),
bedrock_endpoint: None,
aws_profile: None,
@ -670,3 +861,48 @@ impl Config {
}
}
}
#[cfg(test)]
mod rollout_input_tests {
use super::*;
#[test]
fn explicit_rollout_inputs_are_strict_and_diagnosable() {
assert_eq!(parse_rollout_channel("CANARY").unwrap(), "canary");
assert!(parse_rollout_channel("stabel")
.unwrap_err()
.contains("unknown rollout channel"));
assert!(parse_rollout_features("native-bedrock").is_ok());
let error = parse_rollout_features("native_bedrok").unwrap_err();
assert!(error.contains("native_bedrok"));
assert!(error.contains("native_bedrock"));
}
#[test]
fn legacy_false_flags_remain_effective_rollout_disables() {
let args = CliArgs::try_parse_from([
"headroom-proxy",
"--upstream",
"http://127.0.0.1:9",
"--enable-responses-streaming",
"false",
"--enable-bedrock-native",
"false",
])
.unwrap();
let config = Config::from_cli(args);
for feature in [Feature::OpenAiResponsesStreaming, Feature::NativeBedrock] {
let decision = config.rollout.decision(feature);
assert!(!decision.enabled);
assert!(decision.disabled);
assert_eq!(
decision.reason,
headroom_core::rollout::FeatureDecisionReason::Disabled
);
}
assert!(!config.enable_responses_streaming);
assert!(!config.enable_bedrock_native);
}
}

View file

@ -13,6 +13,11 @@ pub async fn healthz() -> impl IntoResponse {
Json(json!({ "ok": true, "service": "headroom-proxy" }))
}
/// Effective rollout state of this running Rust proxy process.
pub async fn rollout_status(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(state.config.rollout.to_value())
}
/// Upstream health: GETs upstream `/healthz`. Returns 200 when reachable +
/// 2xx, 503 otherwise. The endpoint name is reserved by the proxy and is
/// not forwarded; operators must not name a real upstream route this.
@ -39,3 +44,18 @@ pub async fn healthz_upstream(State(state): State<AppState>) -> Response {
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Config;
#[tokio::test]
async fn rollout_status_exposes_running_snapshot() {
let state = AppState::new(Config::for_test("http://127.0.0.1:9".parse().unwrap())).unwrap();
let expected = state.config.rollout.snapshot_digest();
let Json(payload) = rollout_status(State(state)).await;
assert_eq!(payload["snapshot_digest"], expected);
assert_eq!(payload["qualification_eligible"], true);
}
}

View file

@ -28,9 +28,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
max_body_bytes = config.max_body_bytes,
rewrite_host = config.rewrite_host,
graceful_shutdown_timeout_s = config.graceful_shutdown_timeout.as_secs(),
rollout_channel = config.rollout.config.channel.as_str(),
rollout_features_enabled = ?config.rollout.enabled(),
rollout_features_disabled = ?config.rollout.config.disabled,
unsafe_allow_unstable_features = config.rollout.config.unsafe_allow_unstable,
rollout_registry_digest = %config.rollout.registry_digest,
rollout_snapshot_digest = %config.rollout.snapshot_digest(),
qualification_eligible = config.rollout.qualification_eligible(),
"headroom-proxy starting"
);
// Session-sticky beta headers only run inside the compression
// interceptor: with `--compression` off the proxy is a strict
// byte-pipe and never mutates headers. Say so loudly at startup —
// an operator reading `beta_header_sticky=enabled` (the default)
// must not believe the protection is active when it isn't.
if config.beta_header_sticky.is_enabled() && !config.compression {
tracing::warn!(
event = "beta_header_sticky_inactive",
beta_header_sticky = config.beta_header_sticky.as_str(),
compression = config.compression,
"beta-header stickiness is enabled but the compression \
interceptor is off; enable --compression (or \
HEADROOM_PROXY_COMPRESSION=1) to activate it"
);
}
let mut state = AppState::new(config.clone())?;
// PR-D1: resolve AWS credentials at startup via the `aws-config`
@ -67,8 +90,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// when an operator opts in via `--enable-kompress`. The warm runs on a
// blocking thread off the request path; cache-only means a cold cache
// just leaves it deferred (PlainText passes through) rather than stalling
// startup. The always-on structural compressors + CodeCompressor need no
// startup. The structural compressors and opt-in CodeCompressor need no
// such gate.
headroom_core::transforms::set_code_compressor_enabled(config.enable_code_compressor);
headroom_core::transforms::set_kompress_enabled(config.enable_kompress);
if config.enable_kompress {
tokio::task::spawn_blocking(|| {

View file

@ -98,11 +98,11 @@ pub const METRIC_PROXY_RESPONSE_STATUS_COUNT_TOTAL_HELP: &str =
// for `proxy_image_generation_call_log_redacted_total`,
// `wrap_rtk_invocations_total`, and `wrap_rtk_tokens_saved_per_session`
// were removed because the underlying counters had no production
// emit site on the Rust side. The same metrics are exported by the
// Python proxy (`headroom/proxy/prometheus_metrics.py`) which is the
// natural owner: image redaction is a Python-proxy operation and RTK
// invocation tracking lives in the wrap CLI, both Python-side
// surfaces. See `docs/observability.md`.
// emit site on the Rust side. Image redaction is exported by the
// Python proxy (`headroom/proxy/prometheus_metrics.py`), its natural
// owner. The two `wrap_rtk_*` names are gone for good: the rtk
// integration they measured has been removed from Headroom.
// See `docs/observability.md`.
// ---------- shared label keys ----------

View file

@ -283,10 +283,10 @@ pub fn record_response_status(status: &str, reason: Option<&str>, request_id: &s
// Phase G PR-G3 remediation (C3 + C4): the image-redacted counter
// and the wrap_rtk_invocations counter were originally registered
// here but neither had a production emit site that crossed the
// Python/Rust boundary. Both have moved Python-side
// (`headroom.proxy.request_logger::redactions_total` and
// `headroom.cli.wrap_rtk_metrics::rtk_invocation_counts`) and the
// Python proxy's `/metrics` exporter surfaces them — see
// Python/Rust boundary. The image-redacted counter moved Python-side
// (`headroom.proxy.request_logger::redactions_total`) and the Python
// proxy's `/metrics` exporter surfaces it; the RTK counter is gone
// entirely along with the rtk integration itself — see
// `docs/observability.md` for the placement decision. Keeping a
// dead Rust counter would (a) violate the "no dead metrics
// registered" review finding and (b) mislead Phase H canary

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use std::time::Instant;
use axum::body::{to_bytes, Body};
use axum::extract::{ConnectInfo, DefaultBodyLimit, State, WebSocketUpgrade};
use axum::extract::{ConnectInfo, DefaultBodyLimit, FromRequestParts, State, WebSocketUpgrade};
use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode, Uri};
use axum::response::IntoResponse;
use axum::routing::{any, get, post};
@ -17,6 +17,7 @@ use futures_util::{StreamExt as _, TryStreamExt};
use http_body_util::BodyExt;
use crate::cache_stabilization;
use crate::cache_stabilization::beta_sticky::BetaProvider;
use crate::cache_stabilization::drift_detector::{
compute_structural_hash, derive_session_key, observe_drift, ApiKind, DriftState,
};
@ -24,7 +25,7 @@ use crate::compression;
use crate::config::Config;
use crate::error::ProxyError;
use crate::headers::{build_forward_request_headers, filter_response_headers};
use crate::health::{healthz, healthz_upstream};
use crate::health::{healthz, healthz_upstream, rollout_status};
use crate::websocket::ws_handler;
// Phase F PR-F1: imported as `classify_auth_mode` to make the call
// site self-documenting. `AuthMode` is re-exported under the same
@ -66,6 +67,13 @@ pub struct AppState {
/// request body — so this can be cloned freely into every handler
/// path that buffers the body.
pub drift_state: DriftState,
/// Session-sticky beta-header tracker (parity port of the Python
/// `SessionBetaTracker`, PR-A6): per-`(provider, session)` LRU of
/// `anthropic-beta` / `openai-beta` tokens, unioned across turns
/// so a client dropping a token mid-conversation doesn't rotate
/// the upstream prefix-cache key. Shares the drift detector's
/// session identity (same `derive_session_key` output).
pub beta_sticky: cache_stabilization::beta_sticky::BetaStickyState,
/// PR-D4: GCP ADC bearer-token source for Vertex routes. Default:
/// [`crate::vertex::adc::GcpAdcTokenSource`] constructed lazily;
/// the actual ADC chain is only resolved when the first Vertex
@ -111,6 +119,9 @@ impl AppState {
client,
bedrock_credentials: None,
drift_state: DriftState::new(DRIFT_DETECTOR_CAPACITY),
beta_sticky: cache_stabilization::beta_sticky::BetaStickyState::new(
cache_stabilization::beta_sticky::BETA_TRACKER_CAPACITY,
),
vertex_token_source,
})
}
@ -146,6 +157,7 @@ pub fn build_app(state: AppState) -> Router {
let mut router = Router::new()
.route("/healthz", get(healthz))
.route("/healthz/upstream", get(healthz_upstream))
.route("/rollout/status", get(rollout_status))
// PR-D3: Prometheus scrape endpoint. Renders the global
// registry in text format. The handler is stateless — no
// `AppState` needed — and idempotent across concurrent
@ -177,14 +189,14 @@ pub fn build_app(state: AppState) -> Router {
// publisher endpoints look like
// `POST /v1beta1/projects/{p}/locations/{l}/publishers/anthropic/models/{m}:rawPredict`
// (and `:streamRawPredict`). The trailing `:<verb>` is awkward
// in axum's `:param` syntax, so we capture the entire trailing
// segment as `:model_action` and split on the last `:` inside
// in axum's `{param}` syntax, so we capture the entire trailing
// segment as `{model_action}` and split on the last `:` inside
// the dispatcher. Both verbs share the same axum route shape
// — matchit can't distinguish two patterns that overlap on the
// literal parameter. The verb dispatch lives in
// [`crate::vertex::handle_vertex_predict_dispatch`].
.route(
"/v1beta1/projects/:project/locations/:location/publishers/anthropic/models/:model_action",
"/v1beta1/projects/{project}/locations/{location}/publishers/anthropic/models/{model_action}",
post(crate::vertex::handle_vertex_predict_dispatch),
);
@ -207,11 +219,11 @@ pub fn build_app(state: AppState) -> Router {
// Bedrock handlers identically.
let bedrock_router: Router<AppState> = Router::new()
.route(
"/model/:model_id/invoke",
"/model/{model_id}/invoke",
post(crate::bedrock::invoke::handle_invoke),
)
.route(
"/model/:model_id/converse",
"/model/{model_id}/converse",
post(crate::bedrock::invoke::handle_invoke),
)
// PR-D2/PR-D5: streaming counterparts. Bedrock's protocol is
@ -223,11 +235,11 @@ pub fn build_app(state: AppState) -> Router {
// processing pipeline, so both route to the same handler.
// See `bedrock::invoke_streaming`.
.route(
"/model/:model_id/invoke-with-response-stream",
"/model/{model_id}/invoke-with-response-stream",
post(crate::bedrock::invoke_streaming::handle_invoke_streaming),
)
.route(
"/model/:model_id/converse-stream",
"/model/{model_id}/converse-stream",
post(crate::bedrock::invoke_streaming::handle_invoke_streaming),
)
.route_layer(axum::middleware::from_fn(
@ -269,18 +281,18 @@ pub fn build_app(state: AppState) -> Router {
post(crate::handlers::conversations::handle_conversations_create),
)
.route(
"/v1/conversations/:conversation_id",
"/v1/conversations/{conversation_id}",
get(crate::handlers::conversations::handle_conversations_get)
.post(crate::handlers::conversations::handle_conversations_update)
.delete(crate::handlers::conversations::handle_conversations_delete),
)
.route(
"/v1/conversations/:conversation_id/items",
"/v1/conversations/{conversation_id}/items",
post(crate::handlers::conversations::handle_conversations_items_create)
.get(crate::handlers::conversations::handle_conversations_items_list),
)
.route(
"/v1/conversations/:conversation_id/items/:item_id",
"/v1/conversations/{conversation_id}/items/{item_id}",
get(crate::handlers::conversations::handle_conversations_item_get)
.delete(crate::handlers::conversations::handle_conversations_item_delete),
);
@ -303,17 +315,22 @@ pub fn build_app(state: AppState) -> Router {
async fn catch_all(
State(state): State<AppState>,
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
ws: Option<WebSocketUpgrade>,
req: Request<Body>,
) -> Response<Body> {
if is_websocket_upgrade(req.headers()) {
if let Some(ws) = ws {
let (mut parts, body) = req.into_parts();
if is_websocket_upgrade(&parts.headers) {
// axum 0.8 requires optional extractors to opt in explicitly, and
// WebSocketUpgrade intentionally does not. Extract it only after the
// upgrade headers have identified this as a WebSocket request.
if let Ok(ws) = WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
let req = Request::from_parts(parts, body);
return ws_handler(ws, state, client_addr, req).await;
}
// Header says websocket but axum didn't extract it (likely missing
// Sec-WebSocket-Key) — fall through to HTTP forwarding which will
// surface the upstream error.
}
let req = Request::from_parts(parts, body);
forward_http(state, client_addr, req)
.await
.unwrap_or_else(|e| e.into_response())
@ -707,6 +724,41 @@ pub(crate) async fn forward_http(
let session_key = derive_session_key(headers, &client_addr, &parsed, kind);
let hash = compute_structural_hash(&parsed, kind);
observe_drift(&state.drift_state, &session_key, hash);
// Session-sticky provider beta headers — port of the
// Python PR-A6 `SessionBetaTracker`. Beta headers are
// part of the bytes that determine the upstream
// prefix-cache key; a client dropping a token between
// turns rotates the key and re-writes the whole
// prefix at the customer's cost. Forward the
// per-conversation union instead. See
// `cache_stabilization::beta_sticky` for the behavior
// contract, the auth-mode rationale (applies to every
// mode, like the Python handler), and the one
// documented divergence from Python (per-conversation
// keying). Reuses the drift detector's `session_key`
// so both cache-stability subsystems agree on
// conversation identity. Mutates upstream-bound
// HEADERS only; body bytes stay untouched (Phase-A
// cache-safety invariant).
if state.config.beta_header_sticky.is_enabled() {
let provider = match endpoint {
compression::CompressibleEndpoint::AnthropicMessages => {
BetaProvider::Anthropic
}
compression::CompressibleEndpoint::OpenAiChatCompletions
| compression::CompressibleEndpoint::OpenAiResponses => {
BetaProvider::OpenAi
}
};
cache_stabilization::beta_sticky::apply_sticky_betas(
&state.beta_sticky,
provider,
&session_key,
&mut outgoing_headers,
&request_id,
);
}
}
}
let outcome = match endpoint {

View file

@ -218,10 +218,10 @@ async fn run_ws_pump(
fn ax_to_tg(m: AxMsg) -> Option<TgMsg> {
Some(match m {
AxMsg::Text(t) => TgMsg::Text(t.to_string()),
AxMsg::Binary(b) => TgMsg::Binary(b.to_vec()),
AxMsg::Ping(p) => TgMsg::Ping(p.to_vec()),
AxMsg::Pong(p) => TgMsg::Pong(p.to_vec()),
AxMsg::Text(t) => TgMsg::Text(t.to_string().into()),
AxMsg::Binary(b) => TgMsg::Binary(b.to_vec().into()),
AxMsg::Ping(p) => TgMsg::Ping(p.to_vec().into()),
AxMsg::Pong(p) => TgMsg::Pong(p.to_vec().into()),
AxMsg::Close(Some(cf)) => TgMsg::Close(Some(TgCloseFrame {
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(cf.code),
reason: cf.reason.to_string().into(),
@ -232,10 +232,10 @@ fn ax_to_tg(m: AxMsg) -> Option<TgMsg> {
fn tg_to_ax(m: TgMsg) -> Option<AxMsg> {
Some(match m {
TgMsg::Text(t) => AxMsg::Text(t.as_str().to_string()),
TgMsg::Binary(b) => AxMsg::Binary(b.to_vec()),
TgMsg::Ping(p) => AxMsg::Ping(p.to_vec()),
TgMsg::Pong(p) => AxMsg::Pong(p.to_vec()),
TgMsg::Text(t) => AxMsg::Text(t.as_str().to_string().into()),
TgMsg::Binary(b) => AxMsg::Binary(b.to_vec().into()),
TgMsg::Ping(p) => AxMsg::Ping(p.to_vec().into()),
TgMsg::Pong(p) => AxMsg::Pong(p.to_vec().into()),
TgMsg::Close(Some(cf)) => AxMsg::Close(Some(CloseFrame {
code: cf.code.into(),
reason: cf.reason.to_string().into(),

View file

@ -104,7 +104,7 @@ async fn bedrock_classified_as_oauth() {
auth_mode.as_str().to_string()
}
let app = Router::new()
.route("/model/:model_id/invoke", post(probe))
.route("/model/{model_id}/invoke", post(probe))
.route_layer(axum::middleware::from_fn(classify_and_attach_auth_mode));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();

View file

@ -0,0 +1,529 @@
//! End-to-end coverage for session-sticky provider beta headers
//! (`cache_stabilization::beta_sticky` — Rust port of the Python
//! proxy's PR-A6 `SessionBetaTracker`).
//!
//! The scenario every test guards: a client (Claude Code, Codex CLI)
//! sends `anthropic-beta: a,b` on turn 1 and drops `b` on turn 2 of
//! the SAME conversation. Beta headers are part of the bytes that
//! determine the upstream prefix-cache key, so the drop rotates the
//! key and the provider re-writes the whole prefix at the customer's
//! cost. The proxy must forward the per-conversation union instead.
//!
//! These tests boot a real Rust proxy in front of a wiremock upstream
//! and assert on the headers/bytes the upstream actually receives:
//!
//! - dropped tokens are re-injected on later turns (Anthropic,
//! OpenAI Chat, OpenAI Responses — all three intercepted routes);
//! - conversation identity works both via the explicit
//! `x-headroom-session-id` opt-in AND via the body-derived
//! conversation discriminator (no explicit header — the realistic
//! Claude Code shape);
//! - the union NEVER invents tokens the client didn't send: no beta
//! header in → no beta header out, and separate conversations don't
//! leak tokens into each other;
//! - `--beta-header-sticky disabled` forwards the client value
//! verbatim (diagnostic opt-out, Python
//! `HEADROOM_BETA_HEADER_STICKY=disabled` parity);
//! - the body is forwarded byte-equal (SHA-256) while the header is
//! rewritten — the mechanism mutates request headers, never body
//! bytes (Phase-A cache-safety contract).
mod common;
use common::start_proxy_with;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Everything the upstream saw for one request: selected header
/// values (lower-case names) + raw body bytes.
#[derive(Clone)]
struct Seen {
beta: Option<String>,
session_id_header: Option<String>,
body: Vec<u8>,
}
type Captures = Arc<Mutex<Vec<Seen>>>;
/// Mount a capture-everything mock for `route` on the upstream. The
/// `beta_header` name is which provider beta header to record
/// (`anthropic-beta` / `openai-beta`).
async fn mount_capture(upstream: &MockServer, route: &str, beta_header: &'static str) -> Captures {
let captured: Captures = Arc::new(Mutex::new(Vec::new()));
let captured_clone = captured.clone();
Mock::given(method("POST"))
.and(path(route))
.respond_with(move |req: &wiremock::Request| {
let get = |name: &str| {
req.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
};
captured_clone.lock().unwrap().push(Seen {
beta: get(beta_header),
session_id_header: get("x-headroom-session-id"),
body: req.body.clone(),
});
ResponseTemplate::new(200).set_body_string(r#"{"ok":true}"#)
})
.mount(upstream)
.await;
captured
}
fn anthropic_body(turns: &[(&str, &str)]) -> Vec<u8> {
let messages: Vec<serde_json::Value> = turns
.iter()
.map(|(role, content)| json!({"role": role, "content": content}))
.collect();
serde_json::to_vec(&json!({
"model": "claude-sonnet-4-5",
"max_tokens": 32,
"messages": messages,
}))
.unwrap()
}
fn openai_chat_body(turns: &[(&str, &str)]) -> Vec<u8> {
let messages: Vec<serde_json::Value> = turns
.iter()
.map(|(role, content)| json!({"role": role, "content": content}))
.collect();
serde_json::to_vec(&json!({
"model": "gpt-4o",
"messages": messages,
}))
.unwrap()
}
fn openai_responses_body(text: &str) -> Vec<u8> {
serde_json::to_vec(&json!({
"model": "gpt-4o",
"input": [{"role": "user", "content": text}],
}))
.unwrap()
}
async fn post(
client: &reqwest::Client,
url: String,
body: Vec<u8>,
headers: &[(&str, &str)],
) -> reqwest::Response {
let mut req = client
.post(url)
.header("content-type", "application/json")
.body(body);
for (name, value) in headers {
req = req.header(*name, *value);
}
req.send().await.expect("proxy reachable")
}
#[tokio::test]
async fn anthropic_dropped_beta_token_reinjected_with_explicit_session_header() {
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
// Turn 1: two beta tokens.
let resp = post(
&client,
url.clone(),
anthropic_body(&[("user", "hello")]),
&[
(
"anthropic-beta",
"context-management-2025-06-27,interleaved-thinking-2025-05-14",
),
("x-headroom-session-id", "conv-explicit-1"),
],
)
.await;
assert_eq!(resp.status(), 200);
// Turn 2, same conversation: the client dropped the second token.
let resp = post(
&client,
url,
anthropic_body(&[("user", "hello"), ("assistant", "hi"), ("user", "next")]),
&[
("anthropic-beta", "context-management-2025-06-27"),
("x-headroom-session-id", "conv-explicit-1"),
],
)
.await;
assert_eq!(resp.status(), 200);
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[0].beta.as_deref(),
Some("context-management-2025-06-27,interleaved-thinking-2025-05-14"),
"turn 1 forwards the client value unchanged"
);
assert_eq!(
seen[1].beta.as_deref(),
Some("context-management-2025-06-27,interleaved-thinking-2025-05-14"),
"turn 2 must re-inject the dropped token so the upstream \
prefix-cache key stays byte-stable"
);
// PR-A5 invariant intact: the internal session header never
// crosses the upstream boundary.
assert!(seen.iter().all(|s| s.session_id_header.is_none()));
proxy.shutdown().await;
}
#[tokio::test]
async fn anthropic_conversation_keyed_without_explicit_session_header() {
// The realistic Claude Code shape: no `x-headroom-session-id`;
// conversation identity comes from the credential arm + the
// first-message discriminator inside `derive_session_key`.
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
let auth = ("authorization", "Bearer oauth-workspace-token");
post(
&client,
url.clone(),
anthropic_body(&[("user", "conversation opener")]),
&[("anthropic-beta", "a,b"), auth],
)
.await;
// Same conversation (same opener, grown transcript), token "b"
// dropped.
post(
&client,
url,
anthropic_body(&[
("user", "conversation opener"),
("assistant", "reply"),
("user", "follow-up"),
]),
&[("anthropic-beta", "a"), auth],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(seen[1].beta.as_deref(), Some("a,b"));
proxy.shutdown().await;
}
#[tokio::test]
async fn openai_chat_dropped_beta_token_reinjected() {
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/chat/completions", "openai-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/chat/completions", proxy.url());
post(
&client,
url.clone(),
openai_chat_body(&[("user", "hello")]),
&[
("openai-beta", "assistants=v2,realtime=v1"),
("x-headroom-session-id", "conv-oai-1"),
],
)
.await;
post(
&client,
url,
openai_chat_body(&[("user", "hello"), ("assistant", "hi"), ("user", "next")]),
&[
("openai-beta", "assistants=v2"),
("x-headroom-session-id", "conv-oai-1"),
],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(seen[1].beta.as_deref(), Some("assistants=v2,realtime=v1"));
proxy.shutdown().await;
}
#[tokio::test]
async fn openai_responses_dropped_beta_token_reinjected() {
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/responses", "openai-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/responses", proxy.url());
post(
&client,
url.clone(),
openai_responses_body("hello"),
&[
("openai-beta", "responses=v1,tools=v2"),
("x-headroom-session-id", "conv-resp-1"),
],
)
.await;
post(
&client,
url,
openai_responses_body("hello again"),
&[
("openai-beta", "responses=v1"),
("x-headroom-session-id", "conv-resp-1"),
],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(seen[1].beta.as_deref(), Some("responses=v1,tools=v2"));
proxy.shutdown().await;
}
#[tokio::test]
async fn anthropic_fully_omitted_beta_header_regains_union() {
// The headline docs claim: "sends a token in turn N and omits it
// in turn N+1" — here the whole header disappears, not just one
// token, and the union must be re-added through real axum/reqwest
// plumbing.
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
post(
&client,
url.clone(),
anthropic_body(&[("user", "hello")]),
&[
("anthropic-beta", "context-management-2025-06-27"),
("x-headroom-session-id", "conv-omit-1"),
],
)
.await;
// Turn 2: no anthropic-beta header at all.
post(
&client,
url,
anthropic_body(&[("user", "hello"), ("assistant", "hi"), ("user", "next")]),
&[("x-headroom-session-id", "conv-omit-1")],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[1].beta.as_deref(),
Some("context-management-2025-06-27"),
"a fully omitted beta header must be restored from session state"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn disabled_flag_forwards_client_value_verbatim() {
use headroom_proxy::config::BetaHeaderSticky;
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
c.beta_header_sticky = BetaHeaderSticky::Disabled;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
post(
&client,
url.clone(),
anthropic_body(&[("user", "hello")]),
&[
("anthropic-beta", "a,b"),
("x-headroom-session-id", "conv-d1"),
],
)
.await;
post(
&client,
url,
anthropic_body(&[("user", "hello"), ("assistant", "hi"), ("user", "next")]),
&[
("anthropic-beta", "a"),
("x-headroom-session-id", "conv-d1"),
],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[1].beta.as_deref(),
Some("a"),
"disabled mode must forward the dropped-token value verbatim \
and keep no session state"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn no_client_beta_header_is_never_invented() {
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
for body in [
anthropic_body(&[("user", "hello")]),
anthropic_body(&[("user", "hello"), ("assistant", "hi"), ("user", "next")]),
] {
post(
&client,
url.clone(),
body,
&[("x-headroom-session-id", "conv-n1")],
)
.await;
}
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert!(
seen.iter().all(|s| s.beta.is_none()),
"a session that never sent a beta header must never gain one"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn separate_conversations_do_not_leak_tokens() {
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
post(
&client,
url.clone(),
anthropic_body(&[("user", "conversation A")]),
&[
("anthropic-beta", "token-a"),
("x-headroom-session-id", "conv-A"),
],
)
.await;
post(
&client,
url,
anthropic_body(&[("user", "conversation B")]),
&[
("anthropic-beta", "token-b"),
("x-headroom-session-id", "conv-B"),
],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].beta.as_deref(), Some("token-a"));
assert_eq!(
seen[1].beta.as_deref(),
Some("token-b"),
"conversation B must not inherit conversation A's tokens"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn body_bytes_stay_byte_equal_while_header_is_rewritten() {
// Cache-safety contract: the sticky union mutates request
// HEADERS only. The forwarded body must remain byte-identical
// (SHA-256) to what the client sent — same assertion idiom as the
// model-sanitizer integration tests.
let upstream = MockServer::start().await;
let captured = mount_capture(&upstream, "/v1/messages", "anthropic-beta").await;
let proxy = start_proxy_with(&upstream.uri(), |c| {
c.compression = true;
})
.await;
let client = reqwest::Client::new();
let url = format!("{}/v1/messages", proxy.url());
let turn1 = anthropic_body(&[("user", "hello")]);
let turn2 = anthropic_body(&[("user", "hello"), ("assistant", "hi"), ("user", "next")]);
post(
&client,
url.clone(),
turn1,
&[
("anthropic-beta", "a,b"),
("x-headroom-session-id", "conv-bb"),
],
)
.await;
post(
&client,
url,
turn2.clone(),
&[
("anthropic-beta", "a"),
("x-headroom-session-id", "conv-bb"),
],
)
.await;
let seen = captured.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
// Header was rewritten to the union…
assert_eq!(seen[1].beta.as_deref(), Some("a,b"));
// …but the body bytes are untouched.
assert_eq!(
Sha256::digest(&seen[1].body),
Sha256::digest(&turn2),
"sticky beta union must never mutate body bytes"
);
proxy.shutdown().await;
}

View file

@ -53,7 +53,7 @@ async fn ws_text_and_binary_round_trip() {
for i in 0..5 {
let m = format!("hello-{i}");
ws.send(Message::Text(m.clone())).await.unwrap();
ws.send(Message::Text(m.clone().into())).await.unwrap();
let echoed = ws.next().await.unwrap().unwrap();
match echoed {
Message::Text(t) => assert_eq!(t.as_str(), m),
@ -62,7 +62,7 @@ async fn ws_text_and_binary_round_trip() {
}
for i in 0..5u8 {
let m: Vec<u8> = (0..32u8).map(|b| b ^ i).collect();
ws.send(Message::Binary(m.clone())).await.unwrap();
ws.send(Message::Binary(m.clone().into())).await.unwrap();
let echoed = ws.next().await.unwrap().unwrap();
match echoed {
Message::Binary(b) => assert_eq!(b.to_vec(), m),

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

2
deploy/beacon/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# wrangler local state, caches, and account info — never commit
.wrangler/

View file

@ -0,0 +1,5 @@
{
"name": "headroom-beacon",
"private": true,
"type": "module"
}

103
deploy/beacon/query.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Query the telemetry corpus in R2 with DuckDB.
#
# ./query.sh # fleet summary
# ./query.sh sessions # one row per session (deduped)
# ./query.sh "SELECT ..." # your own SQL against the corpus
#
# Setup, once:
# brew install duckdb
# Cloudflare > R2 > API > Create Account API Token (Object Read only,
# scoped to headroom-telemetry), then put the values in ~/env.txt
# (or any file named by HEADROOM_ENV_FILE):
#
# R2_ACCOUNT_ID=...
# R2_ACCESS_KEY_ID=...
# R2_SECRET_ACCESS_KEY=...
#
# R2_ACCOUNT_TOKEN is Cloudflare's REST-API token and is NOT used here — the
# S3 protocol wants the access-key pair.
set -euo pipefail
BUCKET="${R2_BUCKET:-headroom-telemetry}"
_repo_env="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/.env"
ENV_FILE="${HEADROOM_ENV_FILE:-$HOME/env.txt}"
[ -f "$ENV_FILE" ] || ENV_FILE="$_repo_env"
[ -f "$ENV_FILE" ] || { echo "no env file (~/env.txt or $_repo_env) — see this script's header" >&2; exit 1; }
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
for v in R2_ACCOUNT_ID R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY; do
[ -n "${!v:-}" ] || { echo "$v not set in $ENV_FILE" >&2; exit 1; }
done
command -v duckdb >/dev/null || { echo "duckdb not installed: brew install duckdb" >&2; exit 1; }
# Credentials go in via a heredoc on stdin, never on the command line, so they
# stay out of `ps` and shell history.
SECRET="
INSTALL httpfs; LOAD httpfs;
CREATE OR REPLACE SECRET r2corpus (
TYPE r2,
KEY_ID '${R2_ACCESS_KEY_ID}',
SECRET '${R2_SECRET_ACCESS_KEY}',
ACCOUNT_ID '${R2_ACCOUNT_ID}'
);
"
# The corpus is heartbeats: a session reports every 5 minutes with CUMULATIVE
# totals under one id. So the row with the highest seq per (install, session) is
# the whole session — never SUM across heartbeats, you would count each session
# once per report.
DEDUPE="
CREATE OR REPLACE TEMP VIEW sessions AS
SELECT * FROM read_ndjson('r2://${BUCKET}/sessions/**/*.json', union_by_name = true)
QUALIFY row_number() OVER (
PARTITION BY resource['headroom.install_id'], session.id
ORDER BY session.seq DESC
) = 1;
"
case "${1:-summary}" in
summary)
# Fleet rates come from summing raw counts. Averaging the per-session
# rates.*_pct fields would weight a 10-token session equal to a 1M one.
QUERY="
SELECT count(*) AS sessions,
count(DISTINCT resource['headroom.install_id']) AS installs,
sum(session.turns) AS turns,
sum(tokens.saved) AS tokens_saved,
sum(tokens.tool_saved) AS tool_tokens_saved,
round(sum(tokens.attempted) * 100.0
/ nullif(sum(tokens.original), 0), 2) AS eligible_pct,
round(sum(tokens.saved) * 100.0
/ nullif(sum(tokens.attempted), 0), 2) AS yield_pct,
round(sum(tokens.saved) * 100.0
/ nullif(sum(tokens.original), 0), 2) AS saved_pct,
-- saved_pct/yield_pct above are context-compression only, because
-- tool_saved never lands in original/attempted. This is the
-- dashboard headline (server.py `savings_percent`): tool-schema
-- savings on BOTH sides, since deferred schemas were attempted work
-- that succeeded whole. On a tool-heavy fleet the two differ several-
-- fold, so say which one you are quoting.
round(sum(tokens.saved + tokens.tool_saved) * 100.0
/ nullif(sum(tokens.original + tokens.tool_saved), 0), 2)
AS all_layers_pct,
sum(failures) AS failures
FROM sessions;"
;;
sessions)
QUERY="
SELECT resource['headroom.install_id'][1:8] AS install,
session.id, session.seq, session.turns, session.duration_s,
tokens.original, tokens.attempted, tokens.saved,
rates.saved_pct, rates.eligible_pct, rates.yield_pct,
providers, models, skips
FROM sessions
ORDER BY session.duration_s DESC
LIMIT 50;"
;;
*) QUERY="$1" ;;
esac
printf '%s\n%s\n%s\n' "$SECRET" "$DEDUPE" "$QUERY" | duckdb -box

View file

@ -0,0 +1,312 @@
{
"resourceLogs": [
{
"resource": {
"attributes": [
{
"key": "service.name",
"value": {
"stringValue": "headroom"
}
},
{
"key": "service.version",
"value": {
"stringValue": "0.34.0"
}
},
{
"key": "headroom.install_id",
"value": {
"stringValue": "00000000000000000000000000000000"
}
},
{
"key": "os.type",
"value": {
"stringValue": "darwin"
}
},
{
"key": "host.arch",
"value": {
"stringValue": "arm64"
}
}
]
},
"scopeLogs": [
{
"scope": {
"name": "headroom.telemetry.session"
},
"logRecords": [
{
"timeUnixNano": "1785731364402434048",
"body": {
"kvlistValue": {
"values": [
{
"key": "schema_version",
"value": {
"intValue": "1"
}
},
{
"key": "session",
"value": {
"kvlistValue": {
"values": [
{
"key": "id",
"value": {
"stringValue": "sample0000000001"
}
},
{
"key": "seq",
"value": {
"intValue": "0"
}
},
{
"key": "duration_s",
"value": {
"intValue": "4210"
}
},
{
"key": "turns",
"value": {
"intValue": "47"
}
},
{
"key": "ended",
"value": {
"stringValue": "active"
}
},
{
"key": "final",
"value": {
"boolValue": false
}
}
]
}
}
},
{
"key": "tokens",
"value": {
"kvlistValue": {
"values": [
{
"key": "original",
"value": {
"intValue": "890000"
}
},
{
"key": "attempted",
"value": {
"intValue": "410000"
}
},
{
"key": "input",
"value": {
"intValue": "570000"
}
},
{
"key": "output",
"value": {
"intValue": "41000"
}
},
{
"key": "saved",
"value": {
"intValue": "320000"
}
},
{
"key": "tool_saved",
"value": {
"intValue": "48000"
}
},
{
"key": "cache_read",
"value": {
"intValue": "210000"
}
},
{
"key": "cache_write",
"value": {
"intValue": "30000"
}
},
{
"key": "uncached",
"value": {
"intValue": "650000"
}
}
]
}
}
},
{
"key": "rates",
"value": {
"kvlistValue": {
"values": [
{
"key": "saved_pct",
"value": {
"doubleValue": 35.96
}
},
{
"key": "eligible_pct",
"value": {
"doubleValue": 46.07
}
},
{
"key": "yield_pct",
"value": {
"doubleValue": 78.05
}
},
{
"key": "cache_read_pct",
"value": {
"doubleValue": 23.6
}
},
{
"key": "overhead_pct",
"value": {
"doubleValue": 1.96
}
}
]
}
}
},
{
"key": "compression",
"value": {
"kvlistValue": {
"values": [
{
"key": "transforms",
"value": {
"kvlistValue": {
"values": [
{
"key": "crush",
"value": {
"intValue": "47"
}
}
]
}
}
},
{
"key": "overhead_ms_total",
"value": {
"intValue": "1840"
}
},
{
"key": "latency_ms_total",
"value": {
"intValue": "94000"
}
},
{
"key": "passthrough_turns",
"value": {
"intValue": "0"
}
},
{
"key": "response_cache_hits",
"value": {
"intValue": "3"
}
}
]
}
}
},
{
"key": "skips",
"value": {
"kvlistValue": {
"values": []
}
}
},
{
"key": "providers",
"value": {
"arrayValue": {
"values": [
{
"stringValue": "anthropic"
}
]
}
}
},
{
"key": "models",
"value": {
"arrayValue": {
"values": [
{
"stringValue": "claude-sonnet-4-5-20250929"
}
]
}
}
},
{
"key": "failures",
"value": {
"intValue": "2"
}
},
{
"key": "failure_statuses",
"value": {
"kvlistValue": {
"values": [
{
"key": "529",
"value": {
"intValue": "2"
}
}
]
}
}
}
]
}
}
}
]
}
]
}
]
}

View file

@ -0,0 +1,214 @@
/**
* Self-check for scheduled()'s hourly compaction. node test-rollup.mjs [dir]
*
* The one thing that must never drift: rollupHour() and the QUALIFY in
* headroom-beacon-stats/beacon.sh have to agree on which heartbeat wins. If
* they disagree the reports get quietly wrong rather than loudly broken, so
* this asserts the JS picks exactly the max-seq row per (install, session).
*
* Point it at a directory of real beacon objects to check against the corpus:
* aws s3 sync s3://headroom-telemetry/sessions/dt=.../hh=.../ /tmp/hr/ ...
* node test-rollup.mjs /tmp/hr
* With no argument it runs on a small fixture and needs no network.
*/
import { readdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { oldestRawDay, rollupHour } from './worker.js';
// R2 returns at most 1000 keys per list page, so on a real hour (~4,000
// objects) the cursor loop in rollupHour is load-bearing. The stub paginates at
// a deliberately tiny size so that loop is exercised by every case below: with
// a single-page stub, a regression that dropped the cursor would still print
// "ok" while silently rolling up only the first page of every hour.
const PAGE = 3;
/** The slice of the R2 binding rollupHour uses, backed by a plain object. */
function stubBucket(files, { failKeys = new Set() } = {}) {
const written = {};
const reads = [];
return {
written,
reads,
list: async ({ prefix, cursor, delimiter }) => {
const keys = Object.keys(files)
.filter((k) => k.startsWith(prefix))
.sort();
if (delimiter) {
const seen = new Set();
for (const k of keys) {
const cut = k.indexOf(delimiter, prefix.length);
if (cut >= 0) seen.add(k.slice(0, cut + 1));
}
return { objects: [], delimitedPrefixes: [...seen], truncated: false };
}
const start = cursor ? keys.indexOf(cursor) : 0;
const page = keys.slice(start, start + PAGE);
const next = start + PAGE;
return {
objects: page.map((key) => ({ key })),
truncated: next < keys.length,
cursor: next < keys.length ? keys[next] : undefined,
};
},
get: async (key) => {
reads.push(key);
if (failKeys.has(key)) throw new Error(`simulated R2 failure: ${key}`);
if (!(key in files)) return null;
return { text: async () => files[key] };
},
put: async (key, body) => {
written[key] = body;
},
};
}
const beacon = (install, id, seq) =>
JSON.stringify({ resource: { 'headroom.install_id': install }, session: { id, seq } });
const PART = 'dt=2026-08-06/hh=14';
/** Run rollupHour against a stub bucket and decode whatever it wrote. */
async function run(files, opts = {}) {
const CORPUS = stubBucket(files, opts);
const spend = { read: 0 };
let threw = null;
let out = null;
try {
out = await rollupHour({ CORPUS }, PART, spend);
} catch (err) {
threw = err;
}
const body = CORPUS.written[`rollup/${PART}/data.ndjson`];
return {
threw,
spend,
wrote: out ? out.wrote : 0,
empty: `rollup/${PART}/empty` in CORPUS.written,
keys: Object.keys(CORPUS.written),
rows: body ? body.split('\n').map((l) => JSON.parse(l)) : [],
};
}
// 1. Highest seq wins, out-of-order input, one row per (install, session).
// More objects than PAGE, so the list cursor loop runs.
{
const files = {
[`sessions/${PART}/a.json`]: [beacon('i1', 's1', 3), beacon('i1', 's2', 1)].join('\n'),
[`sessions/${PART}/b.json`]: beacon('i1', 's1', 9),
[`sessions/${PART}/c.json`]: beacon('i1', 's1', 7),
// Same session id under a different install must not collapse together.
[`sessions/${PART}/d.json`]: beacon('i2', 's1', 2),
[`sessions/${PART}/e.json`]: beacon('i1', 's1', 5),
};
const { rows, spend, threw } = await run(files);
assert.equal(threw, null);
// 5 objects at PAGE=3 is two pages: proves the cursor loop, which is
// load-bearing at the real ~4,000 objects/hour.
assert.ok(Object.keys(files).length > PAGE, 'fixture must span pages');
assert.equal(spend.read, 5, 'reads every object across every page');
assert.equal(rows.length, 3, 'one row per (install, session)');
const seq = Object.fromEntries(
rows.map((r) => [`${r.resource['headroom.install_id']} ${r.session.id}`, r.session.seq])
);
assert.deepEqual(seq, { 'i1 s1': 9, 'i1 s2': 1, 'i2 s1': 2 });
}
// 2. An unparseable record loses only itself. Content this Worker wrote with
// JSON.stringify never becomes valid later, so blocking the hour on it would
// strand the hour rather than one record.
{
const files = {
[`sessions/${PART}/a.json`]: '{ this is not json',
[`sessions/${PART}/b.json`]: `\n${beacon('i1', 's1', 4)}\n`,
};
const { rows, threw } = await run(files);
assert.equal(threw, null, 'corrupt content does not abandon the hour');
assert.deepEqual(rows.map((r) => r.session.seq), [4], 'survives a corrupt object');
}
// 3. A failed get is transient, so the hour must NOT be written — a rollup is
// built once and then trusted forever, so a short read would silently become
// the permanent record.
{
const files = {
[`sessions/${PART}/a.json`]: beacon('i1', 's1', 1),
[`sessions/${PART}/b.json`]: beacon('i1', 's2', 1),
};
const { threw, keys } = await run(files, {
failKeys: new Set([`sessions/${PART}/b.json`]),
});
assert.ok(threw, 'a failed get throws so the hour is retried');
assert.deepEqual(keys, [], 'nothing written on a partial read');
}
// 4. Spend is reported even when the hour throws. Charging a flat guess instead
// lets a run that failed late overshoot the subrequest ceiling.
{
const files = Object.fromEntries(
Array.from({ length: 7 }, (_, i) => [`sessions/${PART}/o${i}.json`, beacon('i1', `s${i}`, 1)])
);
const { threw, spend } = await run(files, {
failKeys: new Set([`sessions/${PART}/o6.json`]),
});
assert.ok(threw);
assert.equal(spend.read, 7, 'caller sees real spend, not a guess');
}
// 5. An empty hour writes a marker, not a zero-byte NDJSON. Without it the hour
// stays "missing" and is re-listed on every run forever.
{
const { rows, empty, keys } = await run({});
assert.deepEqual(rows, []);
assert.ok(empty, 'empty hour leaves a marker');
assert.ok(
keys.every((k) => !k.endsWith('.ndjson')),
'no zero-byte ndjson for readers to special-case'
);
}
// 6. oldestRawDay floors the backfill. A fixed lookback window silently strands
// every hour older than it once analysis stopped reading sessions/.
{
const CORPUS = stubBucket({
'sessions/dt=2026-08-03/hh=01/a.json': beacon('i1', 's1', 1),
'sessions/dt=2026-08-06/hh=14/b.json': beacon('i1', 's2', 1),
'sessions/dt=2026-08-07/hh=00/c.json': beacon('i1', 's3', 1),
});
assert.equal(await oldestRawDay({ CORPUS }), '2026-08-03');
assert.equal(await oldestRawDay({ CORPUS: stubBucket({}) }), null, 'empty bucket -> null');
}
// 7. Against real objects, if a directory was given: same answer as the QUALIFY
// in beacon.sh, which is `count(DISTINCT install||session)` rows, each
// carrying that pair's max seq.
const dir = process.argv[2];
if (dir) {
const files = {};
for (const f of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
files[`sessions/${PART}/${f}`] = readFileSync(`${dir}/${f}`, 'utf8');
}
const { rows, spend, threw } = await run(files);
assert.equal(threw, null);
const expected = new Map();
for (const text of Object.values(files)) {
for (const line of text.split('\n')) {
if (!line.trim()) continue;
const r = JSON.parse(line);
const k = `${r.resource?.['headroom.install_id']} ${r.session?.id}`;
expected.set(k, Math.max(expected.get(k) ?? -1, r.session?.seq ?? 0));
}
}
assert.equal(spend.read, Object.keys(files).length);
assert.equal(rows.length, expected.size, 'row count matches DISTINCT sessions');
for (const r of rows) {
const k = `${r.resource['headroom.install_id']} ${r.session.id}`;
assert.equal(r.session.seq, expected.get(k), `max seq for ${k}`);
}
console.log(
`real corpus: ${spend.read} objects -> ${rows.length} sessions in 1 object` +
` (${Math.ceil(spend.read / PAGE)} list pages)`
);
}
console.log('ok');

361
deploy/beacon/worker.js Normal file
View file

@ -0,0 +1,361 @@
/**
* Headroom telemetry beacon receiver.
*
* This file is open source on purpose. It is the other half of the promise
* made in headroom/telemetry/session.py: users can read exactly what the
* client sends AND exactly what happens to it on arrival. "Trust us" is not a
* privacy policy.
*
* Deployed at otlp.headroomlabs.ai. Three jobs:
*
* 1. Allowlist. Drop every field not on ALLOWED_KEYS before anything is
* written. This is the only privacy control that works retroactively
* if a future client version ships a bug that leaks a field, we cannot
* patch the installs already in the wild, but we can stop storing it
* here in one deploy.
*
* 2. Flatten. OTLP AnyValue nesting is portable but miserable to query
* ({"kvlistValue":{"values":[{"key":"tokens",...}]}}). We keep OTLP on
* the wire so the backend stays vendor-swappable, and store plain JSON so
* DuckDB can read it without unwrapping anything.
*
* 3. Fan out. R2 for the durable corpus; optionally a metrics vendor for
* dashboards. Adding a destination is one more call here never a
* client release.
*
* What this deliberately does NOT do: log, store, or forward the source IP.
* Cloudflare offers it as cf-connecting-ip; it is the one field that would
* deanonymise install_id, so it is never read.
*/
// Mostly mirrors the payload built by _Session.payload(); an extension may
// also emit its own event carrying one of these top-level keys. A key absent
// here is dropped, not stored. Adding a metric means adding it here first —
// that friction is the point, and it is also the only privacy control that
// works retroactively, so it must land BEFORE any client starts sending the
// key or that traffic is silently discarded and unrecoverable.
const ALLOWED_KEYS = [
'schema_version',
'session',
'tokens',
'rates',
'compression',
'skips',
'sources',
'providers',
'models',
'failures',
'failure_statuses',
// Model-routing summary. Emitted by a routing extension rather than by the
// proxy itself -- see proxy/route_advice.py for the decision seam. Same rule
// as everything above: counters and model ids, no free text. Allowlisted
// here so the corpus can answer what the proxy alone cannot -- a provider's
// real minimum cacheable prefix, how long a cache actually survives, and how
// far predicted cache hits are from the ones that happened.
'routing',
];
// Resource attributes we keep. Same rule: allowlist, not denylist.
const ALLOWED_RESOURCE = [
'service.name',
'service.version',
'headroom.install_id',
'headroom.install_mode',
'headroom.stack',
'os.type',
'host.arch',
];
// A beacon event is ~2KB. Anything far past that is a bug or an attack.
const MAX_BODY_BYTES = 64 * 1024;
/** OTLP AnyValue -> plain JS. The inverse of _any_value() in session.py. */
function unwrap(value) {
if (value == null) return null;
if ('stringValue' in value) return value.stringValue;
if ('boolValue' in value) return value.boolValue;
if ('intValue' in value) return Number(value.intValue);
if ('doubleValue' in value) return value.doubleValue;
if ('arrayValue' in value) return (value.arrayValue.values || []).map(unwrap);
if ('kvlistValue' in value) {
const out = {};
for (const kv of value.kvlistValue.values || []) out[kv.key] = unwrap(kv.value);
return out;
}
return null;
}
function pick(obj, allowed) {
const out = {};
if (!obj || typeof obj !== 'object') return out;
for (const key of allowed) {
if (key in obj) out[key] = obj[key];
}
return out;
}
/** OTLP ExportLogsServiceRequest -> flat, allowlisted records. */
function extract(payload) {
const records = [];
for (const rl of payload.resourceLogs || []) {
const resource = {};
for (const attr of rl.resource?.attributes || []) {
resource[attr.key] = unwrap(attr.value);
}
const cleanResource = pick(resource, ALLOWED_RESOURCE);
for (const sl of rl.scopeLogs || []) {
for (const rec of sl.logRecords || []) {
const body = unwrap(rec.body);
if (!body || typeof body !== 'object') continue;
records.push({
...pick(body, ALLOWED_KEYS),
resource: cleanResource,
// Server-stamped. A client clock can be wrong or forged; this is the
// timestamp partitioning and retention actually rely on.
received_at: new Date().toISOString(),
});
}
}
}
return records;
}
// ----------------------------------------------------------------- rollup --
//
// The corpus is one object per heartbeat, ~1KB each — 65k on 2026-08-06 and
// climbing. DuckDB reads them correctly, but a full `pull` is ~100k HTTPS round
// trips for 95MB: minutes of pure per-object latency, no real bytes or compute.
// Listing the bucket alone took 88 seconds.
//
// This job collapses each COMPLETE hour into one object under rollup/, keeping
// only the highest-seq heartbeat per (install, session). One measured hour
// (dt=2026-08-06/hh=14): 3,938 objects and 3,938 rows in, 1 object and 1,061
// rows out. Analysis reads rollup/**, never sessions/**. Raw is left exactly as
// written, so any rollup can be rebuilt by deleting it.
//
// Hourly rather than daily because every R2 binding call is a subrequest: a day
// is ~65k of them against a 10k-per-invocation ceiling, an hour is ~4k.
const READ_BUDGET = 60000; // objects per run; see [limits] in wrangler.toml
// A get costs ~45ms of round trip and almost no CPU, so this is what decides
// whether a run finishes: at 20 an hour took ~3 minutes, against a 15-minute
// wall clock for a cron invocation. Raise it if an hour ever stops fitting.
const FANOUT = 100; // concurrent R2 gets
const partition = (d) =>
`dt=${d.toISOString().slice(0, 10)}/hh=${d.toISOString().slice(11, 13)}`;
/**
* One hour of heartbeats -> one deduped NDJSON object.
*
* Returns `{ read, wrote }`. Spend is reported through the mutable `spend`
* accumulator so the caller still knows it even when this throws: the budget
* has to track real spend, and a flat guess lets a run that failed late
* overshoot the subrequest ceiling and get killed inside an hour that would
* otherwise have succeeded.
*
* Writes nothing unless the whole hour read cleanly. A rollup is built once and
* then treated as done forever, so a partial read would silently become the
* permanent record better to write nothing and let the next run retry.
*/
export async function rollupHour(env, part, spend = { read: 0 }) {
const best = new Map();
let failed = 0; // transient: retry the hour
let corrupt = 0; // permanent: record and move on
let cursor;
do {
const page = await env.CORPUS.list({ prefix: `sessions/${part}/`, cursor });
for (let i = 0; i < page.objects.length; i += FANOUT) {
// allSettled, not all: one transient R2 error among the ~4,000 gets in a
// real hour would otherwise reject the batch and discard the whole hour.
const settled = await Promise.allSettled(
page.objects
.slice(i, i + FANOUT)
.map((o) => env.CORPUS.get(o.key).then((r) => (r ? r.text() : null)))
);
for (const outcome of settled) {
spend.read++;
// A miss counts as a failure too. The key came from a LIST, so the
// object existed; treating it as empty would quietly shrink the rollup.
if (outcome.status !== 'fulfilled' || outcome.value === null) {
failed++;
continue;
}
for (const line of outcome.value.split('\n')) {
if (!line) continue;
let rec;
try {
rec = JSON.parse(line);
} catch {
// Counted and logged, but NOT a reason to abandon the hour. A
// failed get is transient and worth retrying; content this Worker
// itself wrote with JSON.stringify does not become valid later, so
// blocking on it would strand the hour until its raw objects
// expire and then lose the whole hour instead of one record.
corrupt++;
continue;
}
// A session heartbeats every 5 minutes carrying CUMULATIVE totals, so
// the highest seq IS the whole session and every earlier row is a
// strict subset. Sessions straddle hours, so readers still dedupe
// across rollups on this same key — this only shrinks each hour.
const id = `${rec.resource?.['headroom.install_id']} ${rec.session?.id}`;
const prev = best.get(id);
if (!prev || (rec.session?.seq ?? 0) > (prev.session?.seq ?? 0)) {
best.set(id, rec);
}
}
}
}
cursor = page.truncated ? page.cursor : undefined;
} while (cursor);
if (failed) {
throw new Error(`${part}: ${failed} of ${spend.read} objects unreadable`);
}
if (corrupt) {
console.error(`rollup ${part}: skipped ${corrupt} unparseable record(s)`);
}
// A genuinely empty hour gets a marker rather than a zero-byte NDJSON that
// every reader would have to special-case. Without it the hour stays
// "missing" and is re-listed on every run for the life of the bucket.
if (best.size === 0) {
await env.CORPUS.put(`rollup/${part}/empty`, '');
return { read: spend.read, wrote: 0 };
}
await env.CORPUS.put(
`rollup/${part}/data.ndjson`,
[...best.values()].map((r) => JSON.stringify(r)).join('\n'),
{ httpMetadata: { contentType: 'application/x-ndjson' } }
);
return { read: spend.read, wrote: best.size };
}
/** Oldest `dt=` day still under sessions/, or null. One delimited LIST. */
export async function oldestRawDay(env) {
const page = await env.CORPUS.list({ prefix: 'sessions/', delimiter: '/' });
const days = (page.delimitedPrefixes || [])
.map((p) => p.slice('sessions/dt='.length).replace(/\/$/, ''))
.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d))
.sort();
return days.length ? days[0] : null;
}
export default {
/** Hourly cron. Builds every complete hour back to the oldest raw data. */
async scheduled(event, env) {
// Backfill reaches all the way to the oldest surviving raw day, NOT a fixed
// window. A fixed window silently strands everything older than it the
// moment analysis stopped reading sessions/ — the raw objects are still
// there, but nothing would ever compact them, so they vanish from every
// report. Bounding by real data instead means the floor rises only when a
// lifecycle rule actually expires the raw objects.
const oldest = await oldestRawDay(env);
if (!oldest) return;
const floorMs = Date.parse(`${oldest}T00:00:00Z`);
if (Number.isNaN(floorMs)) return;
// Only list from the floor forward. Rollups older than the oldest raw day
// can never be rebuilt, so enumerating them answers nothing — this is what
// keeps the listing bounded by retention rather than by total history.
const done = new Set();
let cursor;
do {
const page = await env.CORPUS.list({
prefix: 'rollup/',
startAfter: `rollup/dt=${oldest}`,
cursor,
});
for (const o of page.objects) {
// Tolerates both `<part>/data.ndjson` and the `<part>/empty` marker.
const rel = o.key.slice('rollup/'.length);
const cut = rel.lastIndexOf('/');
if (cut > 0) done.add(rel.slice(0, cut));
}
cursor = page.truncated ? page.cursor : undefined;
} while (cursor);
// Newest first, so a backlog drains from the present backwards and the
// freshest hour is never the one starved by the budget. Starts one hour
// back: the current hour is still being written to.
let budget = READ_BUDGET;
for (let t = event.scheduledTime - 3600_000; t >= floorMs && budget > 0; t -= 3600_000) {
const part = partition(new Date(t));
if (done.has(part)) continue;
// Shared with rollupHour so a throw still reports what it spent.
const spend = { read: 0 };
try {
await rollupHour(env, part, spend);
} catch (err) {
// Newest-first means an hour that always throws — one grown past the
// subrequest ceiling, say — would otherwise block every older hour
// behind it forever. Skip it and keep draining; it has no marker, so
// the next run retries it.
console.error(`rollup ${part} failed after ${spend.read} objects: ${err}`);
}
budget -= spend.read;
}
},
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('beacon: POST OTLP logs to /v1/logs', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== '/v1/logs') {
return new Response('not found', { status: 404 });
}
const raw = await request.arrayBuffer();
if (raw.byteLength > MAX_BODY_BYTES) {
return new Response('payload too large', { status: 413 });
}
let records;
try {
records = extract(JSON.parse(new TextDecoder().decode(raw)));
} catch {
// Malformed input is not worth a retry storm from clients.
return new Response('bad request', { status: 400 });
}
if (records.length === 0) return new Response(null, { status: 204 });
// Hive-style partitioning so DuckDB can prune by date without a catalog.
// Shares partition() with the rollup: the cron lists `sessions/<part>/`, so
// two independent spellings of this scheme would mean the writer and the
// compactor could drift apart and silently match zero objects.
// ponytail: one object per request. Compacted hourly into rollup/ by
// scheduled() above — analysis reads that, never this.
const key = `sessions/${partition(new Date())}/${crypto.randomUUID()}.json`;
const ndjson = records.map((r) => JSON.stringify(r)).join('\n');
// Respond immediately; durability work continues after the response.
// The client is fire-and-forget and ignores the status anyway — making it
// wait on R2 would only add latency to someone else's coding session.
ctx.waitUntil(
env.CORPUS.put(key, ndjson, {
httpMetadata: { contentType: 'application/x-ndjson' },
})
);
// Optional second lane: forward verbatim OTLP to a metrics backend for
// dashboards. Configured by secret, so it can be added or swapped with a
// `wrangler secret put` and no code change.
if (env.METRICS_OTLP_URL) {
ctx.waitUntil(
fetch(env.METRICS_OTLP_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: env.METRICS_OTLP_AUTH || '',
},
body: JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: records.map((r) => ({ body: { stringValue: JSON.stringify(r) } })) }] }] }),
}).catch(() => {})
);
}
return new Response(null, { status: 204 });
},
};

View file

@ -0,0 +1,62 @@
name = "headroom-beacon"
main = "worker.js"
compatibility_date = "2025-01-01"
# PHASE 1 — deploy to <name>.<subdomain>.workers.dev with no DNS changes.
# Lets the whole path be tested against a real client before headroomlabs.ai
# nameservers move anywhere.
workers_dev = true
# PHASE 2 — the permanent address. Uncomment once headroomlabs.ai is on
# Cloudflare nameservers, then redeploy. This string is baked into every
# released client (DEFAULT_ENDPOINT in headroom/telemetry/session.py), so it can
# never change afterwards — everything behind it can.
#
# Deploying this while the zone is still on Namecheap fails: wrangler cannot
# find the zone. That is the intended guardrail, not a bug.
#
# [[routes]]
# pattern = "otlp.headroomlabs.ai/v1/logs"
# zone_name = "headroomlabs.ai"
# custom_domain = false
# The corpus. R2 rather than S3 specifically for zero egress: training jobs
# re-read the whole dataset, and on S3 that is a recurring bill for data we
# already own.
[[r2_buckets]]
binding = "CORPUS"
bucket_name = "headroom-telemetry"
# Optional metrics lane, added later without touching this file:
# npx wrangler secret put METRICS_OTLP_URL
# npx wrangler secret put METRICS_OTLP_AUTH
# Absent = R2 only, which is the right place to start.
# Hourly compaction of sessions/ into rollup/ — see scheduled() in worker.js.
# At :05 so the hour being rolled up is definitely closed. A >=1h interval also
# buys the 15-minute CPU limit instead of 30s, which the backfill run needs.
[triggers]
crons = ["5 * * * *"]
# Every R2 binding call is a subrequest, and one hour is already ~4k objects.
# The paid default of 10k would cap a run at two hours and stall the backfill
# behind live traffic forever. This only raises a ceiling; a normal run spends
# ~4k. READ_BUDGET in worker.js is what actually bounds the work.
#
# Workers Paid only — on the Free plan this key is rejected outright ("CPU
# limits are not supported for the Free plan"), and the cron could not run
# anyway: Free gives a scheduled handler 10ms of CPU, and parsing an hour of
# heartbeats is tens of ms.
[limits]
subrequests = 100000
[observability]
enabled = true
# Rate limiting is configured in the Cloudflare dashboard, not here — this
# endpoint is unauthenticated by design (anonymity is the product), so it is
# the only thing between the Worker and a bored stranger:
# Security > WAF > Rate limiting rules
# otlp.headroomlabs.ai/v1/logs -> 60 requests / minute / IP
# A real client sends ~2 requests/hour, so that is ~1000x headroom while still
# capping a single abusive source hard.

View file

@ -10,7 +10,7 @@ target "runtime-default" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy"
HEADROOM_EXTRAS = "proxy,bedrock"
RUNTIME_USER = "nonroot"
}
}
@ -19,7 +19,7 @@ target "runtime" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy"
HEADROOM_EXTRAS = "proxy,bedrock"
RUNTIME_USER = "root"
}
}
@ -28,7 +28,7 @@ target "runtime-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy"
HEADROOM_EXTRAS = "proxy,bedrock"
RUNTIME_USER = "nonroot"
}
}
@ -37,7 +37,7 @@ target "runtime-code" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy,code"
HEADROOM_EXTRAS = "proxy,code,bedrock"
RUNTIME_USER = "root"
}
}
@ -46,7 +46,7 @@ target "runtime-code-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime"
args = {
HEADROOM_EXTRAS = "proxy,code"
HEADROOM_EXTRAS = "proxy,code,bedrock"
RUNTIME_USER = "nonroot"
}
}
@ -55,7 +55,7 @@ target "runtime-slim" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy"
HEADROOM_EXTRAS = "proxy,bedrock"
RUNTIME_USER = "root"
}
}
@ -64,7 +64,7 @@ target "runtime-slim-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy"
HEADROOM_EXTRAS = "proxy,bedrock"
RUNTIME_USER = "nonroot"
}
}
@ -73,7 +73,7 @@ target "runtime-code-slim" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy,code"
HEADROOM_EXTRAS = "proxy,code,bedrock"
RUNTIME_USER = "root"
}
}
@ -82,7 +82,7 @@ target "runtime-code-slim-nonroot" {
inherits = ["_common", "docker-metadata-action"]
target = "runtime-slim"
args = {
HEADROOM_EXTRAS = "proxy,code"
HEADROOM_EXTRAS = "proxy,code,bedrock"
RUNTIME_USER = "nonroot"
}
}

View file

@ -10,7 +10,7 @@
# 3. point your LLM client at http://localhost:8787 (proxy)
#
# Just want the proxy without the memory features? You can run the proxy image
# on its own (`docker run -p 8787:8787 ghcr.io/chopratejas/headroom`); the two
# on its own (`docker run -p 8787:8787 ghcr.io/headroomlabs-ai/headroom`); the two
# database services below are only required for the memory/relevance features.
#
# Ports exposed on the host:
@ -38,8 +38,6 @@ services:
# if you want to use a custom OpenAI-compatible API endpoint,
# uncomment and set the following line with the desired URL
# - OPENAI_TARGET_API_URL=https://api.x.ai
# CLI-filtering dashboard figures require the `rtk` binary inside this
# container; it is not installed by this image. See docs/content/docs/docker-install.mdx.
ports:
- "8787:8787"
volumes:

View file

@ -13,8 +13,6 @@ services:
# the Docker bind-mount source and is intentionally different.
HEADROOM_WORKSPACE_DIR: /tmp/headroom-home/.headroom
HEADROOM_CONFIG_DIR: /tmp/headroom-home/.headroom/config
# CLI-filtering dashboard figures require the `rtk` binary inside this
# container; it is not installed by this image. See docs/content/docs/docker-install.mdx.
volumes:
- ${HEADROOM_WORKSPACE:-.}:/workspace
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.headroom:/tmp/headroom-home/.headroom
@ -35,8 +33,6 @@ services:
# above for rationale.
HEADROOM_WORKSPACE_DIR: /tmp/headroom-home/.headroom
HEADROOM_CONFIG_DIR: /tmp/headroom-home/.headroom/config
# CLI-filtering dashboard figures require the `rtk` binary inside this
# container; it is not installed by this image. See docs/content/docs/docker-install.mdx.
ports:
- "${HEADROOM_PORT:-8787}:${HEADROOM_PORT:-8787}"
volumes:

View file

@ -41,7 +41,7 @@ Every call to `client.messages.create()` compresses messages first. The response
`withHeadroom()` returns a proxy around your Anthropic client that intercepts `messages.create()`:
1. Converts Anthropic-format messages to OpenAI format (the compression engine's native format)
1. Converts Anthropic-format messages to OpenAI format
2. Sends them to the Headroom proxy's `/v1/compress` endpoint
3. Converts the compressed messages back to Anthropic format
4. Forwards the request to Anthropic as normal
@ -58,6 +58,10 @@ The adapter handles the full Anthropic message format including content blocks:
This conversion is lossless. Your request and response behave identically to an unwrapped client.
<Callout type="info" title="The conversion is an SDK choice, not an API requirement">
`POST /v1/compress` does no format conversion and compresses Anthropic content blocks natively — see [Message format](/docs/proxy#message-format). If you are calling the endpoint directly (from a gateway, or LiteLLM's `headroom` guardrail), send Anthropic-shaped messages as-is; you get the same shape back. Only this TypeScript adapter converts, because it normalises on OpenAI types internally.
</Callout>
## Options
Pass compression options as the second argument:

View file

@ -47,7 +47,7 @@ In proxy mode the server is a FastAPI app with per-provider handlers (Anthropic,
The proxy assembles a small, ordered pipeline. Every transform is independent, safe to skip, and **fails open** — on any error it returns the content unchanged and the request still goes through.
1. **Tool-result interceptor** *(opt-in)* — light structural interceptors such as ast-grep Read outlining. Off unless you pass `--intercept-tool-results`.
1. **Tool-result interceptor** *(canary opt-in)* — light structural interceptors such as ast-grep Read outlining. Requires `HEADROOM_ROLLOUT_CHANNEL=canary` plus `--intercept-tool-results`.
2. **CacheAligner** *(off by default)* — a detector that reports dynamic-prefix drift (dates, UUIDs, session tokens). It **never mutates, moves, or rewrites** content. It is disabled by default and hard-disabled inside the proxy; it exists to surface prefix-stability metrics, not to change your messages.
3. **ContentRouter** — the workhorse that does essentially all of the compression. See below.

View file

@ -73,13 +73,15 @@ When the LLM calls `headroom_retrieve`:
The client never sees CCR tool calls on the Anthropic and OpenAI proxy paths; Headroom resolves them transparently there.
<Callout type="warning" title="Current Gemini limitation">
Native Gemini requests do not yet run the server-side CCR response handler, so
`headroom_retrieve` is not resolved transparently on that path today. Google's
OpenAI-compatible Gemini endpoint can also return
`finish_reason=MALFORMED_FUNCTION_CALL` on large function-response continuations
after CCR retrieval. If you need fully transparent CCR resolution today, use the
Anthropic or OpenAI proxy paths. See [issue #2041](https://github.com/headroomlabs-ai/headroom/issues/2041).
<Callout type="warning" title="Gemini CCR boundary">
Buffered native Gemini requests resolve `headroom_retrieve` server-side and
return the model's final response. Streaming native Gemini requests keep the
existing forwarding behavior. When a response contains `headroom_retrieve`
alongside a client-owned function call, Headroom preserves both calls for the
client instead of resolving the mixed response. Google's OpenAI-compatible Gemini endpoint can
also return `finish_reason=MALFORMED_FUNCTION_CALL` on large function-response
continuations after CCR retrieval; that separate limitation remains tracked in
[issue #2041](https://github.com/headroomlabs-ai/headroom/issues/2041).
</Callout>
## Phase 4: Context Tracker

View file

@ -18,7 +18,7 @@ Naive truncation breaks code. Cutting a function in half leaves invalid syntax t
| Tier | Languages | Support Level |
|---|---|---|
| Tier 1 | Python, JavaScript, TypeScript | Full AST analysis |
| Tier 2 | Go, Rust, Java, C, C++ | Function body compression |
| Tier 2 | Go, Rust, Java, C, C++, C#, PHP | Function body compression |
## What Gets Preserved vs Compressed

View file

@ -5,27 +5,22 @@ description: All configuration options for the Headroom Python and TypeScript SD
Headroom can be configured via the SDK constructor, proxy command line, environment variables, or per-request overrides.
## CLI Context Tool
## Runtime Rollout Channels
`headroom wrap ...` uses RTK for local shell-output filtering by default.
Set `HEADROOM_CONTEXT_TOOL=lean-ctx` to have wrap commands install or reuse
`lean-ctx` and run `lean-ctx init --agent <tool>` instead of RTK setup.
Headroom uses rollout channels to control which behaviors an already-installed
artifact may expose. They do not select a package or released version.
```bash
export HEADROOM_CONTEXT_TOOL=lean-ctx
headroom wrap claude
headroom wrap codex --prepare-only
```
Supported values are `rtk` and `lean-ctx`; unset defaults to `rtk`.
| Variable | Default | Purpose |
|----------|---------|---------|
| `HEADROOM_ROLLOUT_CHANNEL` | `stable` | Selects `stable`, `beta`, `canary`, or `dev`. |
| `HEADROOM_FEATURES` | unset | Comma-separated feature names to request explicitly. |
| `HEADROOM_DISABLE_FEATURES` | unset | Comma-separated feature names to force off. Disable wins over every enable path. |
| `HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES` | unset | Break-glass override for emergency mitigation only. |
See [Runtime Rollouts](/docs/runtime-rollouts) for policy, provenance, and
contributor rules.
If Codex history disappeared after using an older wrapper, see [Recover Codex State](/docs/codex-recovery) before wrapping Codex again.
The proxy reads RTK lifetime savings with global scope by default so a shared
daemon reports savings across the operator's projects. Set
`HEADROOM_RTK_GAIN_SCOPE=project` to query `rtk gain --project` from the
proxy process working directory.
## SDK Modes (`default_mode` / `headroom_mode`)
These modes apply to SDK usage via `HeadroomClient(default_mode=...)` or per-request `headroom_mode=...`. They are **not** the same as the proxy `--mode` flag.
@ -313,6 +308,7 @@ headroom proxy --learn --min-evidence 3
| `HEADROOM_REQUEST_TIMEOUT` | Request timeout in seconds | `300` |
| `HEADROOM_BETA_HEADER_STICKY` | Controls per-session `anthropic-beta` / `OpenAI-Beta` re-echo. `enabled` (default): the proxy unions beta tokens across turns within a session — if the client sends a token in turn N and omits it in turn N+1, the proxy re-injects it to preserve prefix-cache stability. `disabled`: the client's value is forwarded verbatim with no accumulation. Any other value raises at request time. See [Session Beta Header Tracking](/docs/configuration#session-beta-header-tracking). | `enabled` |
| `HEADROOM_BETA_TRACKER_MAX_SESSIONS` | LRU capacity of the in-memory session beta tracker. Once full, the oldest session entry is evicted. | `1000` |
| `HEADROOM_PROXY_BETA_HEADER_STICKY` | Rust proxy: same per-conversation beta-token union as `HEADROOM_BETA_HEADER_STICKY`, applied to `anthropic-beta` / `openai-beta` on the intercepted `/v1/messages`, `/v1/chat/completions`, and `/v1/responses` routes. Requires the compression interceptor (`HEADROOM_PROXY_COMPRESSION=1`) — with it off the Rust proxy is a strict byte-pipe and this flag has no effect (startup warns). Unlike the Python tracker (keyed on model + system prompt), sessions are keyed per conversation, shared with the cache-drift detector — parallel conversations never inherit each other's tokens. `enabled` default; `disabled` forwards the client value verbatim and keeps no state. Tracker capacity is fixed at 1000 sessions. | `enabled` |
| `HEADROOM_MODEL_ROUTER_ENABLED` | Enable cost-aware model routing. `1`/`true`/`yes`/`on`/`enabled` turns it on and requires `HEADROOM_MODEL_ROUTES`. See [Cost-aware model routing](/docs/configuration#cost-aware-model-routing). | `off` |
| `HEADROOM_MODEL_ROUTES` | JSON array of ordered routing rules for cost-aware model routing (schema below). | -- |
| `HEADROOM_THINKING_COMPACT` | Compact plain-text reasoning that models re-send every turn (Kimi/GLM/DeepSeek `reasoning_content` / inline `<think>`): Kompress it on warm turns, drop it on cold turns. No-op for Claude/Codex/OpenAI (encrypted reasoning). See [Cold-prefix hook](#cold-prefix-hook--reasoning-compaction). | `off` |
@ -418,7 +414,7 @@ precedence rules: explicit argument > per-resource env var > derived
from canonical root > default. Every legacy env var continues to work
unchanged.
See the **[Filesystem Contract](https://github.com/headroomlabs-ai/headroom/blob/main/wiki/filesystem-contract.md)**
See the **[Filesystem Contract](/docs/filesystem-contract)**
page for the full bucket table, plugin-author guidance, and the Docker
naming overlap note (`HEADROOM_WORKSPACE` is *not* the same as
`HEADROOM_WORKSPACE_DIR`).

View file

@ -75,7 +75,7 @@ docker run --rm -it \
`wrap` is host-oriented in Docker-native mode:
- the wrapper starts the Headroom proxy in Docker
- container-side prep writes Headroom config, memory, and selected CLI context-tool setup into mounted host files
- container-side prep writes Headroom config and memory into mounted host files
- the target CLI itself is launched on the host by the wrapper
Supported host wrap flows:
@ -191,9 +191,6 @@ That keeps provider auth and runtime config working without maintaining a separa
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
- For persistent service and task installs, use the Python-native `headroom install ...` workflow — see [Persistent Installs](/docs/persistent-installs).
- For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy/<profile>/`.
- The `rtk` binary is not bundled in the Docker image. Dashboard CLI-filtering
savings figures show as "not installed" (not `0`) until `rtk` is installed
inside the container.
## Next steps

View file

@ -54,8 +54,6 @@ Examples:
| Memory bridge state | `${WORKSPACE_DIR}/bridge_state.json` | — |
| Proxy log directory | `${WORKSPACE_DIR}/logs/` | — |
| HTTP 400 debug dumps | `${WORKSPACE_DIR}/logs/debug_400/` | — |
| Vendored `rtk` binary | `${WORKSPACE_DIR}/bin/rtk[.exe]` | — |
| Vendored `lean-ctx` binary | `${WORKSPACE_DIR}/bin/lean-ctx[.exe]` | — |
| Deployment profiles | `${WORKSPACE_DIR}/deploy/` | — |
| Beacon lock file | `${WORKSPACE_DIR}/.beacon_lock_<port>` | — |

View file

@ -3,7 +3,7 @@ title: Grok Build Integration
description: Route Grok Build traffic through Headroom for token compression and per-project savings attribution.
---
Use `headroom wrap grok-build` to route Grok Build LLM traffic through the local Headroom proxy. The wrapper starts or reuses the proxy, injects a reversible `[model.grok-build]` override into `~/.grok/config.toml` (or `$GROK_HOME/config.toml`), optionally sets up RTK or `lean-ctx`, and prints next steps for launching `grok`.
Use `headroom wrap grok-build` to route Grok Build LLM traffic through the local Headroom proxy. The wrapper starts or reuses the proxy, injects a reversible `[model.grok-build]` override into `~/.grok/config.toml` (or `$GROK_HOME/config.toml`), and prints next steps for launching `grok`.
## Quick Start
@ -30,7 +30,6 @@ headroom unwrap grok-build
| Proxy | Starts the Headroom proxy unless `--no-proxy` is set |
| Model config | Writes or updates `[model.grok-build] base_url` in Grok's `config.toml`, pointing at `http://127.0.0.1:<port>/v1` (with optional `/p/<project>` prefix for savings attribution) |
| Existing config | If you already have a `[model.grok-build]` table, Headroom rewrites `base_url` in place instead of appending a duplicate table (invalid TOML) |
| Context tool | Injects RTK or `lean-ctx` guidance into project `AGENTS.md` unless `--no-context-tool` is set |
| MCP install | `headroom mcp install` can register Headroom MCP via `GrokRegistrar` |
| Backup | Snapshots `config.toml` to `config.toml.headroom-backup` before the first injection |
@ -39,7 +38,6 @@ headroom unwrap grok-build
```bash
headroom wrap grok-build \
--port 8787 \ # Proxy port (default: 8787)
--no-context-tool \ # Skip RTK / lean-ctx setup
--no-proxy \ # Use an existing proxy instead of starting one
--learn \ # Enable live traffic learning
--memory # Enable persistent memory
@ -50,7 +48,6 @@ headroom wrap grok-build \
| Variable | Description |
|---|---|
| `GROK_HOME` | Override Grok config directory (default: `~/.grok`) |
| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK |
| `XAI_API_KEY` | Grok API key (also accepts `GROK_CODE_XAI_API_KEY`) |
## Persistent Install

View file

@ -17,7 +17,8 @@ const messages = [
];
const result = await compress(messages, { model: 'gpt-4o' });
console.log(`Saved ${result.tokensSaved} tokens (${(result.compressionRatio * 100).toFixed(0)}%)`);
// compressionRatio is tokensAfter / tokensBefore, so savings is 1 - ratio.
console.log(`Saved ${result.tokensSaved} tokens (${((1 - result.compressionRatio) * 100).toFixed(0)}%)`);
```
</Tab>
<Tab value="Python">

View file

@ -94,3 +94,24 @@ app.add_middleware(CompressionMiddleware)
```
Response headers include `x-headroom-compressed: true` and `x-headroom-tokens-saved: 1234`.
## Over HTTP (guardrail / gateway)
The options above run Headroom **in** the LiteLLM process. If instead LiteLLM runs as its own proxy and you want it to call Headroom over the network — the guardrail deployment — point it at [`POST /v1/compress`](/docs/proxy#post-v1compress). LiteLLM swaps `messages` for the compressed result and forwards to the provider.
Two things this deployment needs:
```bash
# Headroom is loopback-only by default and answers remote callers with 404.
HEADROOM_COMPRESS_ALLOW_REMOTE=1 headroom proxy
```
<Callout type="warning" title="404 means blocked, not missing">
Without `HEADROOM_COMPRESS_ALLOW_REMOTE=1` a remote caller gets `404`, not `403` — so a misconfigured guardrail looks exactly like a wrong URL. If you also set `HEADROOM_PROXY_TOKEN`, send it as `X-Headroom-Proxy-Token` or you get `401`.
</Callout>
Leave `config.mode` unset. The default pipeline is marker-free, which is what a forward-only caller wants: `mode: "ccr"` emits retrieval markers that are a dangling pointer unless you also inject the `headroom_retrieve` tool and can reach `/v1/retrieve`.
Because LiteLLM passes model names through, send the real one — `claude-sonnet-4-6`, `bedrock/anthropic.claude-3-5-sonnet`, `gemini-2.5-pro` — so Headroom resolves the right tokenizer and context limit. Anthropic-shaped messages need no conversion; see [Message format](/docs/proxy#message-format).
For multi-turn agent loops, set `config.frozen_message_count` to the number of messages the provider has already cached, **and send back the messages you previously forwarded rather than the pristine originals**. Getting this wrong silently destroys the provider's prefix cache — see [Multi-turn usage](/docs/proxy#multi-turn-usage-keeping-the-prefix-cache) for the loop.

View file

@ -37,7 +37,10 @@
"litellm",
"claude-code-vertex",
"claude-code-azure-foundry",
"vscode-claude-code",
"vscode-copilot",
"opencode",
"opencode-deepseek",
"grok-build",
"mcp",
"---Configuration---",
@ -55,6 +58,7 @@
"architecture",
"ci-cd-flows",
"releases",
"runtime-rollouts",
"benchmarks",
"limitations",
"---Help---",

View file

@ -88,6 +88,63 @@ headroom_latency_seconds_bucket{le="0.1"} 1150
headroom_cache_hits_total 456
```
### OpenTelemetry (OTLP) Export
The proxy can also push its counters to any OTLP/HTTP endpoint. Install the extra and set four variables:
```bash
pip install "headroom-ai[proxy,otel]"
```
```bash
HEADROOM_OTEL_METRICS_ENABLED=1
HEADROOM_OTEL_METRICS_ENDPOINT=http://127.0.0.1:4318/v1/metrics
HEADROOM_OTEL_SERVICE_NAME=headroom-proxy
HEADROOM_OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod
```
| Variable | Default | Purpose |
| --- | --- | --- |
| `HEADROOM_OTEL_METRICS_ENABLED` | `0` | Enable Headroom-managed OTLP metric export |
| `HEADROOM_OTEL_METRICS_EXPORTER` | `otlp_http` | `otlp_http` or `console` (local debugging) |
| `HEADROOM_OTEL_METRICS_ENDPOINT` | unset | Full OTLP metrics URL — Headroom does **not** append `/v1/metrics` for you |
| `HEADROOM_OTEL_METRICS_HEADERS` | unset | Comma-separated `key=value` auth headers |
| `HEADROOM_OTEL_METRICS_EXPORT_INTERVAL_MS` | `10000` | Export interval |
| `HEADROOM_OTEL_SERVICE_NAME` | `headroom-proxy` | OTEL `service.name` |
| `HEADROOM_OTEL_RESOURCE_ATTRIBUTES` | unset | Comma-separated resource attributes |
Exported counters include `headroom.proxy.requests`, `headroom.proxy.tokens.input`, and
`headroom.proxy.tokens.output`. `headroom.proxy.tokens.saved` is the all-layer total:
message/compression savings plus tool-schema deferral savings. The component counter
`headroom.proxy.tokens.tool_schema_saved` exposes the deferral portion separately;
`headroom.compression.tokens.saved` remains the compression-pipeline component.
Confirm the exporter is live with `curl -s http://localhost:8787/stats | jq .otel`.
If your application already configures a global OTEL meter provider, leave `HEADROOM_OTEL_*` unset — Headroom records into the ambient provider automatically.
### Dynatrace
Point the exporter at your environment's OTLP API and add the API token as a header. The token needs the `metrics.ingest` scope.
```bash
HEADROOM_OTEL_METRICS_ENABLED=1
HEADROOM_OTEL_METRICS_ENDPOINT="https://<env-id>.live.dynatrace.com/api/v2/otlp/v1/metrics"
HEADROOM_OTEL_METRICS_HEADERS="Authorization=Api-Token dt0c01.XXXX"
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=DELTA
HEADROOM_OTEL_SERVICE_NAME=headroom-proxy
```
<Callout type="warn" title="Delta temporality is not optional">
`OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=DELTA` is required. Dynatrace [only ingests delta counters](https://docs.dynatrace.com/docs/ingest-from/opentelemetry/getting-started/metrics/limitations) and rejects cumulative ones with `UNSUPPORTED_METRIC_TYPE_MONOTONIC_CUMULATIVE_SUM`, while the OTEL SDK default is cumulative. Without this line, every Headroom metric is dropped at ingest and the proxy logs no error.
</Callout>
Restart the proxy, then search the Dynatrace metric explorer for `headroom.proxy.tokens.saved` — data appears within ~30s.
For an ActiveGate deployment, swap the base URL for `https://<activegate>:9999/e/<env-id>/api/v2/otlp/v1/metrics`. If you already run an OpenTelemetry Collector, send Headroom to it instead and add the `cumulativetodelta` processor — then the temporality variable is unnecessary and the collector holds the token.
Trace export is separate: Headroom's self-configured tracing targets Langfuse only. To land its spans in Dynatrace, leave `HEADROOM_LANGFUSE_*` unset and run the proxy under `opentelemetry-instrument` with the standard `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` variables; Headroom records into the ambient tracer provider.
### Health Check
```bash
@ -252,6 +309,44 @@ headroom proxy --budget 10.00
When the budget is exceeded, requests return a budget exceeded error, the `/stats` endpoint shows budget status, and logs indicate the budget state.
### Measured vs Estimated Spend
Every cost record carries a *basis* — where its input-token count came from. When a provider response includes a usage breakdown, the basis is `measured`. When it doesn't, Headroom substitutes its own `tokens_sent` count so input cost isn't dropped from the budget, and the record's basis is `estimated`. Headroom logs one warning per model the first time this happens.
`/stats` keeps the two separable under `cost.budget_basis`:
```json
{
"cost": {
"budget_limit_usd": 10.0,
"budget_period": "daily",
"budget_estimated_basis": "count",
"budget_basis": {
"total_usd": 3.1400,
"measured_usd": 2.9000,
"estimated_usd": 0.2400,
"estimated_pct": 7.6,
"records": 412,
"estimated_records": 31
}
}
}
```
An estimate can drift in either direction, so you choose what it does to the hard limit:
```bash
headroom proxy --budget 10.00 --budget-estimated-basis count # default
```
| Value | Effect |
|-------|--------|
| `count` | Estimated spend consumes the budget like measured spend. The default; matches historical behavior. |
| `ignore` | Estimated spend is still booked and reported, but only provider-reported spend consumes the budget. |
| `block` | Refuse requests once the period holds any estimated spend, rather than enforcing a hard limit against a guess. |
Env: `HEADROOM_BUDGET_ESTIMATED_BASIS`. `headroom doctor` reports the estimated share alongside the budget check.
## Key Metrics to Monitor
| Metric | What It Tells You | Target |

View file

@ -42,10 +42,12 @@ That's it. Every call to `client.chat.completions.create()` compresses the messa
`withHeadroom()` returns a proxy around your OpenAI client that intercepts `chat.completions.create()`:
1. Extracts `messages` from the request params
2. Sends them to the Headroom proxy's `/v1/compress` endpoint
2. Sends them to the Headroom proxy's [`POST /v1/compress`](/docs/proxy#post-v1compress) endpoint
3. Replaces the original messages with the compressed result
4. Forwards the request to OpenAI as normal
The SDK talks to a **local** proxy, which is why no extra configuration is needed: `/v1/compress` is loopback-only by default. If you move the proxy to another host, set `HEADROOM_COMPRESS_ALLOW_REMOTE=1` on it or requests come back `404`.
All other client methods are untouched:
```ts twoslash

View file

@ -0,0 +1,233 @@
---
title: OpenCode + DeepSeek
description: Configure OpenCode to route DeepSeek traffic through the Headroom proxy for compression, output shaping, and savings visibility.
---
Save 20-60% on DeepSeek API costs with Headroom's context compression proxy.
## How it works
```
OpenCode → Headroom Proxy (:8787) → DeepSeek API
↑ compresses input
+ shapes output
```
The proxy sits between OpenCode and DeepSeek. It compresses tool outputs, logs,
and search results before they reach the model, then shapes responses to be
concise. DeepSeek's API is OpenAI-compatible — one flag and you're running.
---
## 1. Install Headroom
```bash
pip install headroom-ai
# or via uv:
uv tool install headroom-ai
```
You get SmartCrusher (structural compression), the proxy, output shaping, and
the MCP server — everything you need.
---
## 2. Get your DeepSeek API key
Sign up at [platform.deepseek.com](https://platform.deepseek.com) and generate
an API key.
Store it somewhere safe:
```bash
export DEEPSEEK_API_KEY="sk-your-deepseek-key-here"
```
---
## 3. Start the proxy
```bash
headroom proxy \
--port 8787 \
--openai-api-url https://api.deepseek.com/v1
```
The proxy auto-detects `api.deepseek.com` and labels itself "DeepSeek" on the
dashboard. Verify it's running:
```bash
curl http://127.0.0.1:8787/health
# → "status": "healthy"
```
To see which models the proxy exposes:
```bash
curl -s http://127.0.0.1:8787/v1/models \
-H "Authorization: Bearer sk-your-key" | jq '.data[].id'
```
### With output shaping (optional)
Output shaping makes the model's responses shorter — fewer tokens, lower cost:
```bash
HEADROOM_ROLLOUT_CHANNEL=beta HEADROOM_OUTPUT_SHAPER=1 HEADROOM_VERBOSITY_LEVEL=2 \
headroom proxy --port 8787 --openai-api-url https://api.deepseek.com/v1
```
Verbosity levels:
| Level | Behavior |
|---|---|
| `1` | Skip preambles/postambles |
| `2` | + Don't restate code/file content already in context (**recommended**) |
| `3` | + Omit rationale unless asked |
| `4` | Maximum — fragments, zero fluff |
---
## 4. Configure OpenCode
**Note:** If you have an existing `~/.config/opencode/opencode.json` (for MCP
servers, etc.), merge the provider section into that file. Having both `.json`
and `.jsonc` in the same directory can cause conflicts.
Edit `~/.config/opencode/opencode.json`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"model": "headroom/deepseek-v4-pro",
"provider": {
"headroom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Headroom Proxy",
"options": {
"baseURL": "http://127.0.0.1:8787/v1",
"apiKey": "sk-your-deepseek-key"
},
"models": {
"deepseek-v4-pro": {
"name": "DeepSeek V4 Pro",
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek-v4-flash": {
"name": "DeepSeek V4 Flash",
"limit": { "context": 1000000, "output": 384000 }
}
}
}
},
"mcp": {
"headroom": {
"type": "local",
"command": ["headroom", "mcp", "serve"],
"enabled": true
}
}
}
```
**Important:** Only include model IDs that appear in the proxy's `/v1/models`
response. OpenCode validates config models against the proxy's model list.
The current DeepSeek model names are `deepseek-v4-pro` and `deepseek-v4-flash`.
`deepseek-chat` and `deepseek-reasoner` are deprecated compatibility aliases.
### Model comparison
| Model | Input / Output (per 1M) | Context | Max Output |
|---|---|---|---|
| `deepseek-v4-pro` | $0.435 / $0.87 | 1M | 384K |
| `deepseek-v4-flash` | $0.14 / $0.28 | 1M | 384K |
Both models support **thinking mode** for step-by-step reasoning (see below).
Switch models at any time with `/model` in OpenCode.
---
## 5. Start OpenCode
```bash
opencode
```
Run `/models` to confirm both DeepSeek models appear under "Headroom Proxy".
Select one with `/model deepseek-v4-flash` or `/model deepseek-v4-pro`.
---
## 6. Check savings
```bash
curl http://127.0.0.1:8787/stats | python3 -m json.tool | grep -A5 compression
```
Or open the dashboard at [http://127.0.0.1:8787/dashboard](http://127.0.0.1:8787/dashboard).
---
## Thinking mode (reasoning)
Both models support thinking mode natively, and DeepSeek enables it by default.
This replaces the deprecated `deepseek-reasoner` (R1) model.
See [DeepSeek's thinking mode docs](https://api-docs.deepseek.com/guides/thinking_mode)
for details on switching between thinking and non-thinking modes.
---
## Common issues
### "Authentication Fails" / Unauthorized
The `apiKey` in OpenCode's config is missing or wrong. OpenCode must send the
API key to the proxy, and the proxy forwards it to DeepSeek. Make sure
`"apiKey": "sk-..."` is set under `options`.
### Models don't appear under "Headroom Proxy"
1. Verify the proxy is running: `curl http://127.0.0.1:8787/health`
2. Check which models the proxy exposes: `curl -s http://127.0.0.1:8787/v1/models -H "Authorization: Bearer sk-your-key"`
3. Make sure your config model IDs match **exactly** what the proxy returns
4. Don't use both `opencode.json` and `opencode.jsonc` in the same config directory — use one file
### Models appear but requests fail
You ran `headroom wrap opencode`. That command replaces your config with Claude
and GPT models. **Do not use `headroom wrap`.** Configure OpenCode manually as
shown above, and launch OpenCode directly with `opencode`.
### "headroom" command not found
`uv tool install` puts binaries in `~/.local/bin/`. Add it to your PATH:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
### Output shaping shows no savings
Output savings are measured against a learned baseline (it compares "what the
model actually emitted" vs "what it would have emitted unshaped"). After a few
sessions, run:
```bash
headroom learn --verbosity --apply
```
This builds the baseline, and `/stats` will show output savings numbers. The
shaper is active immediately — the numbers just need calibration.
---
## What's NOT in this guide
- **Claude or GPT models** — this setup uses DeepSeek exclusively
- **`headroom wrap`** — do not use it; it overrides the config
- **Deprecated model names** — `deepseek-chat` and `deepseek-reasoner` are
compatibility aliases that will be deprecated on 2026-07-24; use
`deepseek-v4-pro` and `deepseek-v4-flash` instead
- **Kompress (ML compression)** — requires extra dependencies; SmartCrusher
handles the majority of use cases
- **Any code changes** — headroom ships full DeepSeek support natively
(model tables, pricing, tokenizers, domain detection)

View file

@ -3,7 +3,7 @@ title: OpenCode Integration
description: Route OpenCode traffic through Headroom for token compression, MCP tools, and cached model access. One command to wrap, one to unwrap.
---
Use `headroom wrap opencode` to route OpenCode LLM traffic through the Headroom proxy with a single command. The wrapper starts or reuses the proxy, writes OpenCode config, injects Headroom MCP tools, adds RTK context filtering, and launches OpenCode with the generated config.
Use `headroom wrap opencode` to route OpenCode LLM traffic through the Headroom proxy with a single command. The wrapper starts or reuses the proxy, writes OpenCode config, injects Headroom MCP tools, and launches OpenCode with the generated config.
The `headroom-opencode` npm package also exports a native OpenCode plugin. The plugin can be used directly from OpenCode config when you want in-process transport interception plus the Headroom retrieve tool.
@ -27,7 +27,6 @@ headroom unwrap opencode
| Provider injection | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json`, pointing at `http://127.0.0.1:<port>/v1` |
| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` with provider, plugin, and optional local MCP config so OpenCode picks up Headroom at launch |
| Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so OpenCode `/connect` providers keep their own routing |
| Context tool | Injects RTK (or `lean-ctx`) instructions into `~/.config/opencode/AGENTS.md` and project `AGENTS.md` |
| MCP setup | Registers the Headroom MCP server (`headroom_compress`, `headroom_retrieve`, `headroom_stats`) |
| Serena MCP | Optionally registers Serena code graph tools (`--no-serena` to skip) |
| Backup | Snapshots `opencode.json` to `opencode.json.headroom-backup` before making any changes |
@ -38,7 +37,6 @@ headroom unwrap opencode
```bash
headroom wrap opencode \
--port 8787 \
--no-rtk \
--no-mcp \
--no-serena \
--code-graph \
@ -71,7 +69,6 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json`
|---|---|
| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, plugin, and optional local MCP config injected by `wrap` |
| `HEADROOM_PROXY_URL` | Proxy URL passed to Headroom MCP when a non-default port is used, and to the native plugin when configured |
| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK |
## Failure Learning

View file

@ -77,3 +77,26 @@ curl http://localhost:8787/v1/chat/completions \
```
Internal `x-headroom-*` headers (including this one) are stripped before the request is forwarded upstream by default — see `HEADROOM_STRIP_INTERNAL_HEADERS` in [Configuration](/docs/configuration).
## Per-request model routing with `request.state.headroom_route`
`x-headroom-base-url` is client-driven and points at one OpenAI-compatible base. When the choice of model belongs to an extension instead of the caller — a router that picks a cheaper model per turn, say — publish it on the request state and Headroom serves that one request from a backend that speaks the target provider:
```python
# middleware or an extension holding the request
request.state.headroom_route = SimpleNamespace(
model="moonshot/kimi-k2", # required
provider="moonshot", # optional; inferred from the model id if absent
reason="cheaper at this prefix length",
)
```
The contract, in `headroom/proxy/route_advice.py`:
- **Absent means unchanged.** No advice — or advice that is malformed, names an unknown provider, or fails to build a backend — and the request takes exactly the path it took before. A routing preference can never take traffic down.
- **Duck-typed**, so an extension does not import Headroom to publish one.
- A **native** provider (`anthropic`) needs no backend switch — rewrite `body["model"]` yourself. A foreign one is translated by a `LiteLLMBackend` built for it, and Headroom writes the model id.
- Backends are **built once per provider** and cached; a provider that fails to build is not retried per request.
- Honored on `/v1/messages` and `/v1/chat/completions`, streaming and non-streaming alike. (Not the Responses API, which does not use the backend abstraction.)
`routemegood` is the reference consumer of this seam: it decides, Headroom routes.

View file

@ -68,7 +68,7 @@ Avoid setting process-wide variables such as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_P
|--------|---------|-------------|
| `--mode token` | | Prioritize token compression; prior turns may be rewritten for maximum savings. |
| `--mode cache` | default | Freeze prior turns to maximize provider prefix-cache hit rate. This is the effective default (see [Savings profiles](#savings-profiles)). |
| `--intercept-tool-results` | `false` | Opt into tool-result interceptors such as ast-grep Read outlining. |
| `--intercept-tool-results` | `false` | Opt into canary tool-result interceptors such as ast-grep Read outlining. Requires `HEADROOM_ROLLOUT_CHANNEL=canary` (or `dev`). |
| `--no-read-lifecycle` | `false` | Disable stale/superseded Read-output compression. |
| `--code-aware` / `--no-code-aware` | disabled | Enable or disable AST-based code compression. Requires `headroom-ai[code]`. |
| `--code-graph` | `false` | Enable the proxy's live code-graph file watcher for the current project. |
@ -249,7 +249,7 @@ Coding agents re-read the same files repeatedly; these control how stale reads a
| Flag / env | Default | Effect |
|---|---|---|
| `--no-read-lifecycle` | lifecycle on | Stop replacing stale/superseded file reads with CCR markers. |
| `--read-maturation` / `HEADROOM_READ_MATURATION` | `false` | *(Experimental)* Hold freshly-read files out of the prefix cache until the file quiesces. |
| `--read-maturation` / `HEADROOM_READ_MATURATION` | `false` | *(Beta)* Hold freshly-read files out of the prefix cache until the file quiesces. Requires `HEADROOM_ROLLOUT_CHANNEL=beta` (or `dev`). |
| `--read-maturation-quiesce-turns` | `5` | Turns of no change before a held read is admitted. |
### Reliability: timeouts, retries, limits
@ -270,7 +270,7 @@ Defers large tool schemas so they don't sit in every request. See [MCP](/docs/mc
| Env | Scope | Effect |
|---|---|---|
| `HEADROOM_TOOL_SEARCH` | proxy (server-side) | Defer MCP/system tool schemas behind a `search_tools` tool. The `coding` profile enables it. |
| `HEADROOM_TOOL_SEARCH` | proxy (server-side) | Defer MCP/system tool schemas behind a `search_tools` tool. **On by default** for Anthropic requests carrying enough tools to be worth it; set `HEADROOM_TOOL_SEARCH=0` to opt out. |
| `ENABLE_TOOL_SEARCH` | client (Claude Code) | Keep Claude Code's own deferred tool-loading active behind a custom base URL ([issue #746](https://github.com/headroomlabs-ai/headroom/issues/746)). Set automatically by `headroom wrap`. |
### Cost-aware model routing
@ -284,7 +284,7 @@ Rewrite the upstream model per request — for example, send small, tool-free ca
| `--telemetry` / `HEADROOM_TELEMETRY` | off | **Local-only** usage stats for your own `/stats`, `/metrics`, and dashboard. Nothing leaves the machine. |
| `--log-file` / `HEADROOM_LOG_FILE` | none | JSONL request/response log. |
| `--log-messages` | `false` | Include full message bodies in the log (may contain sensitive data). |
| `HEADROOM_OTEL_METRICS_ENABLED` | `false` | Export OpenTelemetry metrics (`HEADROOM_OTEL_METRICS_ENDPOINT`, …). |
| `HEADROOM_OTEL_METRICS_ENABLED` | `false` | Export OpenTelemetry metrics (`HEADROOM_OTEL_METRICS_ENDPOINT`, …). See [OTLP export](/docs/metrics#opentelemetry-otlp-export). |
| `HEADROOM_LANGFUSE_ENABLED` | `false` | Emit Langfuse traces (`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`). |
See [Metrics](/docs/metrics) for the Prometheus and Grafana setup.
@ -294,6 +294,7 @@ See [Metrics](/docs/metrics) for the Prometheus and Grafana setup.
| Flag / env | Default | Effect |
|---|---|---|
| `HEADROOM_PROXY_TOKEN` | none | Require a bearer token (`X-Headroom-Proxy-Token`) from non-loopback callers. |
| `HEADROOM_COMPRESS_ALLOW_REMOTE` | `false` | Allow non-loopback callers to reach [`POST /v1/compress`](#post-v1compress). Required to run Headroom as a gateway/sidecar; without it remote callers get `404`. |
| `--offline` / `HEADROOM_OFFLINE` | `false` | Air-gap mode: hard-disable **all** egress (telemetry, update checks, license reporting, model downloads). |
| `--stateless` / `HEADROOM_STATELESS` | `false` | Keep all state in memory; no filesystem writes (disables logs, memory, TOIN). |
| `HEADROOM_STRIP_INTERNAL_HEADERS` | `enabled` | Strip internal `x-headroom-*` headers before forwarding upstream. |
@ -385,6 +386,22 @@ For Codex-compatible clients, the proxy also accepts these alias paths and route
Matching WebSocket and subpath aliases are also supported for Codex flows.
### Codex Live voice WebSocket
The proxy relays Codex Live voice frames without parsing or transforming them.
These paths use the same transparent transport:
- `ws://localhost:8787/v1/live`
- `ws://localhost:8787/v1/codex/live`
- `ws://localhost:8787/backend-api/live`
- `ws://localhost:8787/backend-api/codex/live`
Subscription authentication uses the derived ChatGPT backend path. API-key
authentication preserves the selected OpenAI-compatible base URL and inbound
path. The backend Live suffix defaults to `/live` and can be corrected with
`HEADROOM_CODEX_LIVE_WS_PATH` if the upstream contract changes. The exact
ChatGPT backend path is not confirmed by this proxy documentation.
### `POST /v1internal:streamGenerateContent`
Google Cloud Code Assist / Antigravity compatibility endpoint used by Pi-style `google-gemini-cli` and `google-antigravity` providers.
@ -394,17 +411,79 @@ The proxy also accepts:
### `POST /v1/compress`
Compression-only endpoint. Compresses messages without calling any LLM. Used by the TypeScript SDK.
Compression-only endpoint. Compresses messages and returns them without ever making a **completion request to an LLM provider** — no generation, no provider API key, no upstream chat call. Used by the TypeScript SDK, by LiteLLM's `headroom` guardrail, and by API gateways running Headroom as a sidecar.
**Request:**
```json
{
"messages": [{ "role": "user", "content": "..." }],
"model": "gpt-4o"
}
```
<Callout type="info" title="It does run local ML models">
"No LLM call" means no *generative* request to a provider. Compression itself is ML-backed: **Kompress** is a ModernBERT encoder that scores tokens for retention (classification, not generation), and Magika classifies content types. Both run in-process by default, so budget CPU and memory for the sidecar accordingly.
If `HEADROOM_KOMPRESS_ENDPOINT` is set, Kompress inference is offloaded over HTTP to that model server — **real egress from the sidecar**, which matters if you deployed it expecting none. Only inference goes remote: the CCR store and retrieval markers stay proxy-local, and original content never persists off-box. Leave the variable unset to keep everything in-process, or run with `HEADROOM_DISABLE_KOMPRESS=1` for structural compression only.
</Callout>
<Callout type="warning" title="Loopback-only by default">
This route is restricted to loopback callers and answers everyone else with **`404`**, not `403` — deliberately, so it stays invisible to external scanners. A gateway calling it from another host or pod therefore sees what looks like a missing route.
Both the client IP and the inbound `Host:` header must name loopback. To allow remote callers, set `HEADROOM_COMPRESS_ALLOW_REMOTE=1`. `HEADROOM_PROXY_TOKEN` still applies if set.
</Callout>
#### Message format
The endpoint does **no format conversion**. Whatever shape you send in `messages` is the shape you get back, and both wire formats are compressed natively:
- **OpenAI shape** — `role: "tool"` messages with `tool_call_id`, assistant `tool_calls`
- **Anthropic shape** — content-block lists with `tool_use` / `tool_result` / `thinking` blocks
So an Anthropic-native caller does not need to convert to OpenAI format first. Block types, `tool_use_id`s and message order are all preserved.
`model` selects the tokenizer (per-model, from Headroom's tokenizer registry) and the context limit. Send the real model name — including gateway-prefixed forms like `bedrock/anthropic.claude-3-5-sonnet` or `vertex_ai/claude-sonnet-4@20250514` — so token counts and compression aggressiveness are right.
#### Request
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `messages` | array | yes | Messages to compress, in either wire format. `400` if missing. Empty array returns immediately with zero metrics. |
| `model` | string | yes | Model name. Drives tokenizer + context-limit resolution. `400` if missing. |
| `token_budget` | integer | no | Overrides the model's context limit. Used by callers that need to fit a tighter budget. |
| `config` | object | no | Compression options, below. A non-object value is ignored rather than rejected. |
<Callout type="warning" title="system and tools are ignored">
Only the four fields above are read. Anthropic sends `system` and `tools` **out of band**, alongside `messages` — this endpoint accepts them without complaint (you get a `200`, no warning) and returns neither, so neither is compressed.
Keep carrying both yourself and send them upstream unchanged. Two consequences worth knowing:
- An Anthropic system prompt is not compressed here, even though it is resent on every request.
- Tool-schema compaction and tool-search deferral are not reachable through this endpoint — on tool-heavy traffic those can be the largest share of available savings. Run Headroom as the proxy (rather than calling `/v1/compress`) if you need them.
</Callout>
`config` fields:
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | string | unset | `ccr`, `lossy_inline`, or `lossless_then_lossy`. Unset selects the default marker-free pipeline. Any other value is a `400`. |
| `frozen_message_count` | integer | unset | Pin a prefix: the first N messages are returned byte-for-byte unchanged while staying visible to cross-message transforms like dedup. Set it to the number of messages the provider has already cached so compression cannot rewrite the prefix and bust that cache. Must be a non-negative integer; anything else is a `400`. |
| `compress_user_messages` | boolean | `false` | Also compress user-role messages. |
| `target_ratio` | number | unset | Target compression ratio. |
| `protect_recent` | integer | unset | Leave the last N messages uncompressed. |
| `protect_analysis_context` | boolean | unset | Preserve analysis context blocks. |
**`config.mode` values:**
- **unset (default)** — marker-free. Emits no `<<ccr:…>>` retrieval markers and writes nothing to the CCR store, so you can forward the returned messages straight to a provider. This is the right mode for a gateway or guardrail that just swaps `messages` and forwards.
- **`ccr`** — emits CCR markers and writes to the store. Only for callers that also inject the `headroom_retrieve` tool *and* can reach `/v1/retrieve` (itself loopback-only). Markers are a dangling pointer for the model otherwise.
- **`lossy_inline`** (alias `lossless_then_lossy`) — runs the lossless byte/data fold first, then compresses the folded remainder. Marker-free.
#### Response
| Field | Type | Description |
| --- | --- | --- |
| `messages` | array | Compressed messages, in the shape you sent. |
| `tokens_before` | integer | Token count before compression. |
| `tokens_after` | integer | Token count after compression. |
| `tokens_saved` | integer | `tokens_before - tokens_after`. |
| `compression_ratio` | number | `tokens_after / tokens_before` — so **lower is better**. A ratio of `0.23` means a 77% reduction, not 23%. `1.0` when nothing was compressed. |
| `transforms_applied` | array | Transform labels that ran. |
| `transforms_summary` | object | Per-transform counts. |
| `ccr_hashes` | array | Retrieval hashes for markers inserted (empty unless `mode: "ccr"`). |
**Response:**
```json
{
"messages": [{ "role": "user", "content": "..." }],
@ -413,11 +492,72 @@ Compression-only endpoint. Compresses messages without calling any LLM. Used by
"tokens_saved": 11500,
"compression_ratio": 0.23,
"transforms_applied": ["router:smart_crusher:0.35"],
"ccr_hashes": ["a1b2c3"]
"transforms_summary": { "router:smart_crusher:0.35": 1 },
"ccr_hashes": []
}
```
Set `x-headroom-bypass: true` to skip compression.
#### Headers
`x-headroom-bypass: true` (case-insensitive) skips compression entirely and echoes your messages back with zeroed metrics. The bypass and empty-messages responses omit `transforms_summary`.
#### Errors and fail-open
| Status | Body | When |
| --- | --- | --- |
| `400` | `error.type = "invalid_request"` | Missing `messages` or `model`, malformed JSON, invalid `config.mode`, or invalid `config.frozen_message_count`. |
| `401` | — | `HEADROOM_PROXY_TOKEN` is set and the bearer token is missing or wrong. |
| `404` | — | Non-loopback caller without `HEADROOM_COMPRESS_ALLOW_REMOTE=1`. |
| `503` | `error.type = "compression_error"` | Compression failed unexpectedly. |
Compression **fails open on timeout**: you get `200` with your original messages, zeroed metrics, plus `compression_skipped: true` and `skip_reason: "compression_timeout"`. Always check `compression_skipped` if you need to know whether compression actually ran.
Requests are recorded under `provider="compress"` in `/stats` and `/metrics`.
#### Multi-turn usage: keeping the prefix cache
This is the single most important thing to get right, and the default is not safe for an agent loop.
When Headroom proxies a request itself it watches the provider's cache hit rate turn over turn and freezes the already-cached prefix. `/v1/compress` **cannot do that — it is stateless.** It sees one isolated call and has no idea what the provider already cached.
The provider caches the bytes you **forwarded**. Compression changed those bytes, so your original messages and the ones the provider cached are no longer the same thing — and it is the forwarded version you have to keep reproducing. Send the pristine originals again next turn and the provider sees a different prefix and re-reads it from scratch. On Anthropic a cache read is ~90% cheaper than fresh input, so that can easily cost more than the compression saves.
Compression is also not uniform over a conversation: how hard a message is compressed depends partly on how far it now sits from the end, so an older tool result can fall outside the recent-read protection window as the conversation grows and be compressed harder than it was last turn. Another reason not to rely on re-compression reproducing earlier output.
Two rules:
1. **Pass `config.frozen_message_count`** — how many leading messages the provider has already cached.
2. **Send back your own previous output, not the original messages.** `frozen_message_count` returns those leading messages *exactly as you passed them in* — it pins whatever you hand it. Hand it pristine originals and you get pristine originals back, which is precisely the prefix the provider does not have.
```python
# Keep what you FORWARDED, not what you started with.
forwarded: list[dict] = []
def next_turn(new_messages: list[dict]) -> list[dict]:
body = {
"messages": forwarded + new_messages,
"model": "claude-sonnet-4-6",
# Everything already forwarded is already cached upstream — pin it.
"config": {"frozen_message_count": len(forwarded)},
}
result = requests.post(f"{proxy}/v1/compress", json=body).json()
forwarded[:] = result["messages"] # becomes next turn's frozen prefix
return forwarded
```
<Callout type="warning" title="Re-sending pristine messages every turn silently busts the cache">
Compressing the full original conversation on each turn looks correct — you get a `200` and a positive `tokens_saved` — but the leading messages come back different from the ones the provider cached. You pay for compression *and* for a cache miss. Nothing in the response tells you this happened; watch your provider's cache-read tokens.
</Callout>
Also for multi-turn callers:
- **Leave `config.mode` unset.** The default is marker-free, which is what a forward-only caller wants.
- **Send the real model name** so the tokenizer and context limit resolve correctly — including gateway-prefixed forms.
- **`protect_recent` is not a substitute.** It guards the newest messages; `frozen_message_count` guards the oldest, which is the cached end.
<Callout type="info" title="Not the same as HEADROOM_KOMPRESS_ENDPOINT">
`HEADROOM_KOMPRESS_ENDPOINT` points *outbound* at a remote Kompress ML model server that happens to expose a `/compress` path. It is unrelated to this inbound endpoint.
</Callout>
## Agent wrapping
@ -427,6 +567,9 @@ Use `headroom wrap` to launch supported CLI agents through the local proxy:
# Claude Code
headroom wrap claude
# Claude Code extension in VS Code (configures settings, then starts the proxy)
headroom wrap vscode-claude
# OpenAI Codex
headroom wrap codex
@ -448,6 +591,12 @@ Grok Build reads model endpoints from `~/.grok/config.toml`. `headroom wrap grok
injects or updates `[model.grok-build] base_url` to point at the local proxy, then
run `grok` from the same project directory. See [Grok Build Integration](/docs/grok-build).
The official Claude Code extension reads Claude Code's user settings rather than
the terminal environment. Use `headroom wrap vscode-claude`, reload VS Code after
the first run, and keep the wrapper running. See the
[VS Code Claude Code guide](/docs/vscode-claude-code) for verification and undo
steps.
For environment-driven clients, you can also set the base URL manually:
```bash

View file

@ -203,6 +203,13 @@ Transforms: ['smart_crusher', 'cache_aligner']
## Alternative: proxy mode (zero code changes)
<Callout type="info" title="Using Claude Code inside VS Code?">
Run `headroom wrap vscode-claude`, reload the VS Code window once, and keep the
wrapper running while you use the official Claude Code extension. See the
[complete VS Code Claude Code guide](/docs/vscode-claude-code) for verification,
undo steps, custom profiles, and remote development.
</Callout>
If you do not want to change any code, run Headroom as a proxy and point your existing client at it:
```bash

View file

@ -16,10 +16,11 @@ For the end-to-end visual flow, see [CI/CD Flow Diagrams](/docs/ci-cd-flows).
| Package | Type | Registry | Environment Variable |
|---------|------|----------|----------------------|
| `headroom-ai` | Python | PyPI | `PYPI_PACKAGE` |
| `headroom-ai` | TypeScript SDK | npmjs.org | `NPM_SDK_PACKAGE` |
| `headroom-openclaw` | TypeScript plugin | npmjs.org | `NPM_OPENCLAW_PACKAGE` |
| `@{owner}/headroom-ai` | TypeScript SDK | GitHub Package Registry | — |
| `@{owner}/headroom-openclaw` | TypeScript plugin | GitHub Package Registry | — |
| `headroom-ai` | TypeScript SDK | npmjs.org | `NPM_SDK_PACKAGE` |
| `headroom-openclaw` | TypeScript plugin | npmjs.org | `NPM_OPENCLAW_PACKAGE` |
| `headroom-opencode` | TypeScript plugin | npmjs.org | `NPM_OPENCODE_PACKAGE` |
| `@{owner}/headroom-ai` | TypeScript SDK | GitHub Package Registry | — |
| `@{owner}/headroom-openclaw` | TypeScript plugin | GitHub Package Registry | — |
| `headroom-ai-{version}.tar.gz` / `headroom_ai-{version}-py3-none-any.whl` | Python package distributions | GitHub Release (`{owner}/headroom`) | — |
| `headroom-ai-{version}.tgz` / `headroom-openclaw-{version}.tgz` | Node release assets | GitHub Release (`{owner}/headroom`) | — |
| `ghcr.io/{owner}/headroom` | Docker image | GitHub Container Registry | — |
@ -41,6 +42,7 @@ Release Please calculates the release version from conventional commits and the
- `pyproject.toml` - `[project].version`
- `headroom/_version.py` - `__version__`, synced at build time
- `plugins/openclaw/package.json` - `version`, synced at build time
- `plugins/opencode/package.json` - `version`, synced at build time
- `sdk/typescript/package.json` - `version`, synced at build time
`release.yml` does not commit back to the repo. Version synchronization happens inside the release build workspace.
@ -98,9 +100,10 @@ Installs the built wheels into representative customer environments and imports
Downloads the Python dist artifact and publishes to PyPI via `pypa/gh-action-pypi-publish@release/v1` (trusted publisher).
### publish-npm
Publishes both TypeScript packages to npmjs.org:
- `sdk/typescript/` as `headroom-ai`
- `plugins/openclaw/` as `headroom-openclaw`
Publishes all npm packages to npmjs.org:
- `sdk/typescript/` as `headroom-ai`
- `plugins/openclaw/` as `headroom-openclaw`
- `plugins/opencode/` as `headroom-opencode`
### publish-github-packages
Publishes both Node packages to GitHub Package Registry (`npm.pkg.github.com`) using the current repository owner as the npm scope:
@ -124,10 +127,11 @@ All package names, registry URLs, and environment names are defined as top-level
env:
PYPI_PACKAGE: headroom-ai
PYPI_ENVIRONMENT: pypi
NPM_REGISTRY_URL: https://registry.npmjs.org
NPM_SDK_PACKAGE: headroom-ai
NPM_OPENCLAW_PACKAGE: headroom-openclaw
GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com
NPM_REGISTRY_URL: https://registry.npmjs.org
NPM_SDK_PACKAGE: headroom-ai
NPM_OPENCLAW_PACKAGE: headroom-openclaw
NPM_OPENCODE_PACKAGE: headroom-opencode
GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com
```
To rename a package, update the corresponding constant — all references throughout the workflow update automatically.

View file

@ -0,0 +1,174 @@
---
title: Runtime Rollouts
description: Deterministic runtime feature control for installed Headroom artifacts.
---
Runtime rollout answers one question: **which behaviors may this already-built
Headroom artifact expose in this process?** It is separate from the source and
distribution lifecycle, which decides which commit/artifact is qualified,
released, packaged, and published.
```bash
HEADROOM_ROLLOUT_CHANNEL=canary headroom proxy
```
This runs the installed artifact with canary-eligible runtime features available
according to that artifact's rollout policy. It does **not** install, select, or
run a canary release/version of Headroom.
## Channels and feature policy
Channels are ordered `stable < beta < canary < dev`.
| Channel | Purpose |
|---------|---------|
| `stable` | Default; behavior eligible for normal production use. |
| `beta` | Opt-in behavior backed by automated and limited production evidence. |
| `canary` | Early dogfood behavior still gathering evidence. |
| `dev` | Local development and maintainer experiments. |
Availability and default enablement are separate registry fields. A feature can
be available in `canary` but remain off until explicitly requested; another can
be available and default-enabled in `stable`.
Request a named feature:
```bash
HEADROOM_ROLLOUT_CHANNEL=canary \
HEADROOM_FEATURES=tool_result_interceptors \
headroom proxy --intercept-tool-results
```
Force it off with the kill switch:
```bash
HEADROOM_DISABLE_FEATURES=tool_result_interceptors headroom proxy
```
## Resolution and precedence
CLI arguments, environment variables, and typed configuration are resolved once
at configuration construction. The immutable snapshot is injected into the
proxy and transform pipelines; changing the process environment afterward does
not alter a running proxy.
The existing loopback-only `/admin/runtime-env` endpoint is one narrow
exception: hot-reloading the legacy `HEADROOM_OUTPUT_SHAPER` alias replaces the
proxy's immutable snapshot with a newly resolved snapshot. Channel bounds and
`HEADROOM_DISABLE_FEATURES` still win, and `/stats.rollout` changes with the
effective running decision. Because these overrides are process-local, the
endpoint rejects updates when the built-in server uses multiple workers; restart
the proxy with the desired environment instead. Ambient environment mutation
remains ignored.
Precedence is deterministic:
| Condition | Result |
|-----------|--------|
| Explicit disable | Off, even if defaulted, requested, aliased, or unsafe override is active. |
| Requested below its availability channel, unsafe override active | On with `unsafe_override`. |
| Requested below its availability channel | Off with `blocked_by_channel`. |
| Explicit request in an allowed channel | On with `explicit`. |
| Enabled legacy alias in an allowed channel | On with `legacy_alias`. |
| Default-enabled in the active channel | On with `default`. |
| Otherwise | Off with `not_requested`. |
Legacy feature-specific variables are narrow compatibility aliases only. They
obey channel bounds and explicit disable precedence.
## Unsafe override and invalid input
`HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1` is a break-glass mechanism. It can
cross a channel boundary for a requested feature, but cannot beat an explicit
disable. The runtime remains usable for debugging and emergency reproduction,
while its snapshot reports:
```json
{
"unsafe_override": true,
"qualification_eligible": false,
"qualification_ineligible_reason": "unsafe_rollout_override_active"
}
```
The Python resolver logs a warning and falls back to `stable` for an unknown
channel; unknown feature names are warned and ignored (fail-closed). Explicit
Python diagnostics (`headroom rollout status`) and the Rust front proxy's typed
CLI/environment parser reject unknown channels/features and list valid values
before startup.
## Machine-readable status and provenance
Inspect a supplied configuration without starting the proxy:
```bash
headroom rollout status --json
```
Inspect the actual running process through the supported black-box endpoint:
```bash
curl http://127.0.0.1:8787/stats
```
The Python proxy publishes the object at `/stats.rollout`. The Rust front proxy,
when deployed, publishes its own effective snapshot at `/rollout/status`; this
keeps each process's distinct feature registry and decisions independently
observable.
The `/stats.rollout` object and CLI output contain no secrets. They include:
```json
{
"schema_version": 1,
"policy_version": "1",
"channel": "stable",
"unsafe_override": false,
"registry_digest": "sha256:...",
"snapshot_digest": "sha256:...",
"qualification_eligible": true,
"features": [
{
"name": "tool_result_interceptors",
"available_in": "canary",
"default_enabled_in": null,
"requested": false,
"disabled": false,
"enabled": false,
"decision": "not_requested"
}
]
}
```
`schema_version` versions the external JSON contract. `policy_version` versions
the rollout rules. `registry_digest` is SHA-256 over canonical, ordered feature
definitions. `snapshot_digest` identifies the complete effective runtime state.
Equivalent policies/configurations produce equal digests; material policy or
decision changes do not.
These identities deliberately remain separate from source SHA, artifact SHA-256,
runtime payload SHA-256, and future qualification-policy identities. An external
benchmark can compare `/stats.rollout.registry_digest` and `snapshot_digest`
between A1 passthrough and B Headroom arms without importing Headroom internals.
A mismatch makes the future experiment invalid; benchmark logic itself is out of
scope for runtime rollout.
## Evidence-backed graduation and rollback
Features progress from canary through beta toward stable only with linked
deterministic, integration, and benchmark evidence. **Bake time is evidence, not
qualification by itself.** Stable eligibility is followed by release
qualification before behavior becomes a stable default.
Every rollout-managed behavior must have a fast disable path. Operational
rollback uses `HEADROOM_DISABLE_FEATURES`; source rollback reverts the defining
change. The unsafe override is for diagnostics, not promotion or passing release
evidence.
Contributors should add named registry entries and tests for default behavior,
explicit request, channel blocking, disable precedence, unsafe behavior,
decision reasons, and provenance rather than reading rollout variables inside
implementation components. Python and Rust registries contain features relevant
to their own runtimes, but share channel ordering, precedence, decision reasons,
fail-closed invalid-input semantics, and deterministic identity semantics.

View file

@ -0,0 +1,127 @@
---
title: Use Headroom with Claude Code in VS Code
description: Route the official Claude Code extension through Headroom's local compression proxy.
---
The official Claude Code extension for VS Code embeds Claude Code. Headroom can
route its Anthropic API requests through the same local compression proxy used by
`headroom wrap claude`, without changing your Anthropic sign-in or selected model.
<Callout type="info" title="Using Claude Code in a terminal instead?">
Use `headroom wrap claude`. This page is specifically for Anthropic's official
Claude Code extension inside VS Code.
</Callout>
## Requirements
- VS Code 1.98 or newer
- Anthropic's official Claude Code extension, signed in and working
- Headroom with proxy dependencies: `pip install "headroom-ai[proxy]"`
- Loopback access to `127.0.0.1` from the VS Code extension host
Confirm that Claude Code works normally in VS Code before adding Headroom. This
makes authentication or extension problems easier to distinguish from proxy
configuration problems.
## Quick start
1. Open a terminal in the project you use with Claude Code.
2. Start Headroom:
```bash
headroom wrap vscode-claude
```
Headroom starts its proxy and adds two entries under `env` in the Claude Code
user settings file:
```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/p/your-project",
"ENABLE_TOOL_SEARCH": "true"
}
}
```
`ANTHROPIC_BASE_URL` changes the endpoint, not the selected model.
`ENABLE_TOOL_SEARCH` keeps Claude Code's on-demand tool loading enabled when it
uses a custom endpoint. Existing settings and prior values for both variables are
preserved for restoration. Headroom does not store or replace your Anthropic
credentials.
3. After the first configuration, run **Developer: Reload Window** from the VS
Code Command Palette.
4. Keep the wrapper terminal running and use the Claude Code panel normally.
## Verify that it is working
While the wrapper is running:
1. Open `http://127.0.0.1:8787/health`; it should report a healthy proxy.
2. Send a message in the Claude Code panel.
3. Open the dashboard or proxy log whose locations are printed by the wrapper.
Confirm that the request appears there; savings are recorded with each
completed request.
If the health check succeeds but no request appears in the dashboard or proxy
log, reload the VS Code window and confirm that the extension host can reach the
same `127.0.0.1` as Headroom.
## Settings location
The default user settings file is `~/.claude/settings.json` on macOS and Linux,
or `%USERPROFILE%\.claude\settings.json` on Windows. `CLAUDE_CONFIG_DIR` is
respected when set. To target another profile explicitly:
```bash
headroom wrap vscode-claude --settings-file /path/to/.claude/settings.json
```
Use `--no-configure` to print the settings without editing a file.
The proxy URL includes the current directory as the project attribution name.
Run the wrapper from the intended project directory. If you select another port,
for example `--port 8788`, Headroom writes that same port to the settings file.
## Stop and undo
Press `Ctrl+C` to stop the proxy. The endpoint remains configured so requests
fail closed rather than silently bypassing Headroom while it is stopped. Restart
it with `headroom wrap vscode-claude` before using Claude Code again.
Restore the values that existed before Headroom configured the extension:
```bash
headroom unwrap vscode-claude
```
Headroom records only the two values it owns in a sidecar next to the Claude
settings file. It refuses malformed settings or conflicting edits rather than
overwriting them. Unrelated Claude settings are preserved.
If you used `--settings-file` during setup, pass the same option when undoing it:
```bash
headroom unwrap vscode-claude --settings-file /path/to/.claude/settings.json
```
## Remote development
For Dev Containers, SSH, or WSL, `127.0.0.1` must refer to the environment where
the Claude Code process runs. Run Headroom there or forward the selected port,
and pass that environment's Claude settings file with `--settings-file` when
automatic discovery does not match it.
## Troubleshooting
- Check `http://127.0.0.1:8787/health` while the wrapper is running.
- Run `headroom wrap vscode-claude --port 8788` if port 8787 is occupied.
- Reload the VS Code window after changing Claude Code settings.
- Keep the wrapper process running for the entire Claude Code session. A stopped
proxy intentionally does not fall back to a direct Anthropic connection.
- If configuration reports a conflict, inspect `~/.claude/settings.json`; Headroom
will not replace a managed value that changed after setup.
- If you use `CLAUDE_CONFIG_DIR`, launch Headroom from an environment where it is
set to the same value used by Claude Code.
- This integration is for the Claude Code extension, not the Claude desktop app.

View file

@ -0,0 +1,163 @@
---
title: VS Code Copilot Integration
description: Transparently route the model selected in GitHub Copilot through Headroom on macOS, Windows, and Linux.
---
Headroom integrates below VS Code's native Copilot model picker. It overrides the
Copilot API proxy endpoint, not the model: if the user selects GPT-5.5, a GPT-5.6
variant, Claude Sonnet, Claude Opus, or another Copilot model, the same model ID
travels through Headroom to GitHub's Copilot API.
No `Headroom` model appears in the picker. Headroom does not patch the built-in
extension, terminate TLS, or edit Codex configuration.
## Requirements
- Current stable VS Code with GitHub Copilot enabled and signed in
- A GitHub account with Copilot access
- Headroom with proxy dependencies: `pip install "headroom-ai[proxy]"`
- Loopback access to `127.0.0.1` from the VS Code extension host
## One-time authentication
VS Code keeps its Copilot token in extension secret storage. Headroom deliberately
does not read or modify that encrypted store, so authorize Headroom separately:
```bash
headroom copilot-auth login
```
Open the printed GitHub device URL, enter the code, and approve it. Headroom saves
the reusable OAuth credential in its own auth file with user-only permissions.
At launch it exchanges that credential for a short-lived Copilot API token; that
token remains in the proxy process and is never written to VS Code settings.
## Start
Run from the project whose savings should receive attribution:
```bash
headroom wrap vscode
```
The command:
1. validates Copilot subscription access and resolves the account API endpoint;
2. starts Headroom on `127.0.0.1:8787` with the short-lived upstream token;
3. adds a marker-owned block to VS Code user settings containing
`github.copilot.advanced.debug.overrideProxyUrl` and
`github.copilot.advanced.debug.overrideAuthType`;
4. keeps running until `Ctrl+C` so the local proxy is available to VS Code.
Continue using Copilot's normal model picker. The request body—and therefore the
selected model—is not rewritten by the VS Code integration.
## What is routed
The shipped Copilot extension resolves both its chat/agent endpoint and its
completions-core endpoint through the proxy override. This covers native model
selection without registering duplicate models. Some ancillary Copilot services
(telemetry, GitHub API calls, MCP, embeddings, model discovery, cloud agents) use
separate endpoints and are intentionally not redirected.
```text
Copilot UI: user selects model M
-> native Copilot request with model M
-> http://127.0.0.1:8787/p/<project>/<native Copilot path>
-> Headroom compression, cache alignment, metrics, attribution
-> authenticated GitHub Copilot API, still with model M
-> response from model M
```
## Safe settings lifecycle
Headroom edits only a marked block in VS Code's `settings.json`. Existing JSONC
comments, formatting, trailing commas, and unrelated settings remain byte-for-byte
unchanged. Headroom refuses malformed files, incomplete markers, or a pre-existing
unmanaged Copilot endpoint override instead of overwriting them.
| Platform | Stable VS Code user settings |
|---|---|
| macOS | `~/Library/Application Support/Code/User/settings.json` |
| Windows | `%APPDATA%\\Code\\User\\settings.json` |
| Linux | `${XDG_CONFIG_HOME:-~/.config}/Code/User/settings.json` |
For Insiders, VSCodium, portable installations, a custom `--user-data-dir`, or a
remote extension host, provide the exact user settings file:
```bash
headroom wrap vscode --settings-file /path/to/User/settings.json
```
Use `--no-configure` to print the two settings without editing a file.
## Model coverage
There is no static Headroom model list. Availability stays controlled by Copilot
and the signed-in account. To validate a model, select it in VS Code and send a
short prompt; Headroom forwards the model identifier unchanged. This naturally
covers newly added Copilot models without a Headroom release.
The newest Copilot models may use the OpenAI Responses API instead of the legacy
Chat Completions API. Headroom proxies both routes. Do not treat a model's
`unsupported_api_for_model` response from `/chat/completions` as a proxy failure;
VS Code uses the endpoint supported by that model.
Live verification on July 31, 2026 confirmed the account catalog and a successful
HTTP 200 response through Headroom's `/responses` route for each of these exact
model IDs:
- `gpt-5.5`
- `gpt-5.6-luna`
- `gpt-5.6-sol`
- `gpt-5.6-terra`
Each response retained the requested model ID. Model availability remains subject
to the signed-in user's Copilot plan and organization policy.
The proxy supports the native Copilot OpenAI-compatible request paths used by
GPT and Claude models. Headroom's upstream auth hook replaces local client auth
with the current Copilot API token on every Copilot-bound request.
## Stop and undo
Press `Ctrl+C` to stop the session proxy. The VS Code endpoint setting remains so
future `headroom wrap vscode` runs need no reconfiguration; while the proxy is
stopped, Copilot requests will fail closed instead of bypassing Headroom.
Remove only Headroom's settings block with:
```bash
headroom unwrap vscode
```
Use the same `--settings-file` override used during setup. Other VS Code and
Copilot settings are preserved.
## Remote development
Copilot may run in the local or remote extension host depending on the workspace.
For Dev Containers, SSH, or WSL, `127.0.0.1` must refer to the host running
Headroom. Run Headroom in that environment or forward the chosen port. Portable,
remote, and profile-specific settings should use `--settings-file` explicitly.
## Enterprise
GitHub.com Enterprise Cloud normally requires no override; Headroom uses the API
URL advertised during token exchange. For GitHub Enterprise Server/custom domains,
set `GITHUB_COPILOT_ENTERPRISE_URL` or `GITHUB_COPILOT_ENTERPRISE_DOMAIN` before
both `copilot-auth login` and `wrap vscode`.
## Verification and troubleshooting
- `headroom copilot-auth status` should report `logged in`.
- `http://127.0.0.1:8787/health` should be healthy while the wrapper runs.
- The health payload's OpenAI upstream should be the Copilot API endpoint.
- Select several native models and confirm Headroom metrics show each request.
- If a model is unavailable, verify the Copilot account entitlement; Headroom
does not add or rename models.
- If connection is refused, keep the wrapper running and check loopback/remote
port reachability.
- Use `--port 8788` when the default port is occupied; settings update safely.
- If Headroom refuses settings, repair the reported JSONC/marker conflict or use
`--no-configure` and apply the printed settings manually.

View file

@ -0,0 +1,378 @@
# context-mode → Headroom: enterprise plugin & variant analysis
Analysis date: 2026-07-29. Sources: `/Users/tcms/demo/context-mode` @ v1.0.169, `/Users/tcms/demo/headroom` @ main.
---
## 1. Bottom line
context-mode and Headroom attack the same cost problem at **two different layers**, and they do not
overlap where it matters:
| | context-mode | Headroom |
|---|---|---|
| Interception point | agent **tool-call boundary** (host hooks + MCP) | model **API boundary** (proxy / SDK / MCP) |
| Position relative to context | **pre-context** — data never enters | **in-context** — data already entered, gets squeezed |
| Mechanism | admission control: block, redirect, sandbox, externalize | compression: crush, cache, retrieve |
| Touches the wire request | never | always |
| Loss | lossless (full content in FTS5, queryable) | lossy squeeze + hash rehydrate |
Headroom's own realignment doc identifies its correct compression target as the **live zone**:
"latest user message content + latest `tool_result` + latest `function_call_output` + latest
`local_shell_call_output`" (`REALIGNMENT/00-overview.md`, Phase B).
**That is precisely the payload context-mode intercepts one layer earlier.** Headroom Phase B is
building a Rust engine to compress the latest tool result *after* it hits the wire. context-mode
stops that tool result from being produced at all. These are complements, not competitors — and the
upstream position is strictly cheaper: nothing to compress, nothing to cache-invalidate, no
token-validation fallback needed.
Three strategic unlocks, in order of value:
1. **Cache safety.** Headroom's #1 identified bug class is prompt-cache busting from request
mutation (5 top-tier cache-killer bugs, `REALIGNMENT/00-overview.md`). context-mode has
*structurally zero* cache-bust risk because it never touches the request body.
2. **Subscription safety.** The realignment flags "fingerprint-class subscription-revocation
risks" from `X-Headroom-*` header leakage, `anthropic-beta` mutation and re-serialization on
OAuth/subscription CLIs. A hook-layer product carries none of this — it is invisible to the
upstream. This is a *deployable-where-the-proxy-can't-go* capability.
3. **Proxy-free deployment.** Headroom's value today requires being in the API path
(`127.0.0.1:8787`). Verified live this session: with the proxy down, `headroom_stats` returns all
zeros and `headroom_compress` no-ops. Enterprises that cannot reroute model traffic (TLS trust,
egress policy, subscription auth) currently get nothing. context-mode's hook+MCP model needs no
interposition.
Zero references to context-mode exist in the Headroom tree today — clean slate.
---
## 2. context-mode: portable IP inventory
41,617 lines of TypeScript, 11 MCP tools, 18 host adapters, npm-distributed
(`context-mode@1.0.169`, 8 runtime deps, esbuild-bundled).
Ranked by *how hard it would be for Headroom to rebuild*:
### Tier 1 — genuinely hard, no Headroom equivalent
**1. Cross-host hook adapter layer** — `src/adapters/**` (~10K LOC), `src/adapters/types.ts`,
`src/adapters/detect.ts` (737 lines), `configs/` (18 hosts).
Normalizes three incompatible paradigms — `json-stdio` (Claude Code, Gemini/Qwen, Copilot, Codex,
Kimi, Cursor, Kiro, Antigravity), `ts-plugin` (OpenCode, KiloCode, OpenClaw), `mcp-only` (Zed, Pi,
OMP) — behind one contract: normalized `PreToolUse` / `PostToolUse` / `PreCompact` /
`SessionStart` events, a `PlatformCapabilities` matrix, and a 5-way decision
(`allow | deny | modify | context | ask`). Per-host install, config-format, and self-heal machinery
included (`hooks/heal-partial-install.mjs`, `scripts/plugin-cache-integrity.mjs`).
*Why hard to rebuild:* the value is entirely in the accumulated per-host quirks. There is no spec to
implement against.
**2. Tool-boundary policy engine** — `src/security.ts` (889 lines).
A real policy decision point, not a regex list: glob→regex compilation, chained-command splitting
(`&&`/`;`/`|` with escape awareness), subshell extraction, deny/ask pattern ingestion from host
settings files, project-boundary containment (`evaluateProjectContainment` — Issue #852: an approved
`ctx_execute_file` cannot escape the repo via a path the user couldn't see), and a
**shell-escape scanner** (`SHELL_ESCAPE_PATTERNS`, `extractShellCommands`) that detects
`execSync`/`subprocess`/etc. embedded inside sandboxed *non-shell* code and re-evaluates the escaped
command against policy.
*Why hard to rebuild:* this is the sandbox-escape prevention layer. Getting it wrong is a CVE.
**3. Multi-language sandbox executor** — `src/executor.ts` (785), `src/runPool.ts`,
`src/exit-classify.ts`, `src/truncate.ts`.
12 languages, stdout-only egress, timeouts, background detach, output caps, exit classification.
Enforces the "Think in Code" contract: the agent programs the analysis, only the answer enters
context.
**4. Lossless externalization store** — `src/store.ts` (2,071 lines).
Dual SQLite FTS5 index — a tokenized `chunks` table *plus* a `chunks_trigram` table for
substring/identifier search where BM25 tokenization fails on code — with a `vocabulary` table and
schema migration path. Auto-externalizes any output >100 KB into FTS5 and returns a pointer.
Nothing is discarded; the model queries on demand.
### Tier 2 — valuable, but partially duplicated in Headroom
**5. Counterfactual savings accounting** — `src/session/analytics.ts` (3,085 lines),
`src/session/project-attribution.ts`, `src/session/db.ts` (1,726).
`ContextSavings`, `ThinkInCodeComparison`, `RealBytesStats`, `MultiAdapterLifetimeStats`,
`enumerateAdapterDirs()`. Measures *what would have entered context but didn't* — a different and
harder quantity than Headroom's `savings_ledger.py`, which records actual compression deltas.
Session event ledger + `tool_calls` + resume + per-project attribution.
**6. Multi-vendor pricing catalog** — `src/session/pricing.ts` + `model-prices.json`.
61 curated models × 4 rate buckets (input / output / cache-read / cache-write), refreshed from
litellm, unknown model → `null` rather than a silently wrong Claude rate.
**Overlaps `headroom/pricing/*` heavily. Do not port.**
### Tier 3 — do not port
Compression heuristics, memory/graph/relevance, telemetry transport, dashboard, install UX,
update-check. Headroom has all of these, more mature, and Phase B/H is actively consolidating them.
---
## 3. Headroom's actual extension seams
Verified entry-point groups (all `importlib.metadata`-discovered, all opt-in):
| Seam | Group | Contract | Source |
|---|---|---|---|
| Proxy extension | `headroom.proxy_extension` | `install(app: FastAPI, config: ProxyConfig) -> None` | `headroom/proxy/extensions.py:52` |
| Pipeline extension | `headroom.pipeline_extension` | `on_pipeline_event(PipelineEvent) -> PipelineEvent \| None` over 11 stages | `headroom/pipeline.py:13,68` |
| Learn plugin | `headroom.learn_plugin` | — | `headroom/learn/registry.py:44` |
| Memory text store | `headroom.memory_text` | — | `headroom/memory/config.py:41`, `factory.py:57` |
| Memory vector store | `headroom.memory_vector` | — | `headroom/memory/config.py:34` |
| Memory store | `headroom.memory_store` | — | `headroom/memory/config.py:25` |
| CCR backend | `headroom.ccr_backend` | — | `headroom/cache/compression_store.py:981` |
| Compression hooks | (subclass, not entry point) | `pre_compress` / `compute_biases` / `post_compress` | `headroom/hooks.py:1-31` |
Two things worth noting:
- `headroom/proxy/extensions.py:32` states an explicit **stability contract**: changing
`install(app, config)` or the group name requires a deprecation cycle. This is a supported public
seam, not an accident.
- `headroom/hooks.py:16` says outright: *"Headroom SaaS implements position-aware compression and
cross-turn deduplication via these hooks."* The open-core split is already designed in.
**The exemplar to copy:** `plugins/headroom-oauth2/` — own `pyproject.toml`, own `LICENSE`, own
`SPEC.md`, registers on `headroom.proxy_extension`, dormant until `--proxy-extension oauth2`,
all config via env, "zero core changes." That is the enterprise plugin template.
**The precedent to copy:** `headroom/lean_ctx/installer.py` and `headroom/rtk/installer.py`
Headroom already ships thin installers that adopt sibling products. `plugins/headroom-agent-hooks`
already installs startup hooks into Claude Code and Copilot CLI. The socket exists.
**The gap:** Headroom has *no tool-boundary interception anywhere*. It sees `tool_use`/`tool_result`
only as message content after the fact (`headroom/parser.py`, `headroom/tokenizers/*`). Its
`PipelineStage` enum has no tool-result stage. Everything context-mode does is upstream of
Headroom's earliest hook.
---
## 4. Proposed plugins & variants
Ranked by value ÷ effort.
### P1 — `headroom-recall`: FTS5+trigram lossless store as `headroom.memory_text`
**What:** port `src/store.ts` behind the existing `headroom.memory_text` seam.
**Why this first:** it is the smallest diff onto an *already-existing* contract, and it fixes a real
product limitation. Today `headroom_retrieve(hash)` requires you to *know the hash* — the tool
description literally says "hash comes from compression markers like `[N items compressed... hash=abc123]`".
With an FTS5-backed store you get `retrieve-by-query`: "what did that build log say about OOM"
instead of "paste hash abc123". The trigram index matters specifically because BM25 tokenization
loses identifiers and stack frames.
Composes rather than replaces: `compress` → return squeezed text + hash → store the *original* in
FTS5 → rehydrate by hash **or** by query. Also a natural `headroom.ccr_backend` implementation —
the realignment wants "CCR hardens: persistent backend" (Phase B), and this is one.
**Enterprise variant:** shared team store, retention/TTL policy, per-project scoping (context-mode
already has `project-attribution.ts`), audit of every retrieval.
**Effort:** medium. Reimplement in Python/Rust against Headroom's memory interface, or ship the
node store as a sidecar. Do not port the MCP tool surface — only the store.
### P2 — `headroom-admission`: tool-boundary admission control across 18 hosts
**What:** context-mode's adapter + hook layer, distributed the way `plugins/openclaw` and
`plugins/opencode` already are (TS package under `plugins/`), reporting savings into Headroom's
`savings_ledger.py` JSONL and emitting Headroom pipeline events.
**Why:** this is the strategic piece. It gives Headroom:
- a **pre-wire** enforcement point, upstream of Phase B's live-zone engine, with no cache-bust and
no token-validation fallback required;
- coverage of **18 agent hosts** — the realignment's Phase G wants to "extend wrap CLIs (cline,
continue, goose, openhands)"; this is that work already done, and then some;
- a deployment mode that works under **subscription auth**, where the proxy is a revocation risk.
**Enterprise value — this is the DLP story Headroom cannot currently tell.** A `curl` inside a Bash
tool call never touches the proxy, so Headroom is blind to it. context-mode blocks
`curl`/`wget`/`WebFetch`/inline `fetch()`/`requests.get` at the tool boundary and forces network
egress through `ctx_fetch_and_index`. That converts a token-savings feature into an
**egress-control** feature — a different budget line and a different buyer.
**Effort:** high, but it's mostly packaging + a reporting bridge, not a rewrite. Keep it TypeScript;
Phase H retires Python *proxy* code but explicitly preserves "CLI wrappers, RTK installer" — the
installer layer is the surviving Python, and it can shell out.
### P3 — `headroom-policy` (Enterprise, license-gated): the PDP
**What:** `src/security.ts` as a policy decision point, plus centrally-managed org rulesets.
Two attach points: the hook layer from P2 (tool-level `allow/deny/ask`), and
`headroom.pipeline_extension` at `PRE_SEND` (prompt-level policy). Feeds `headroom/audit/`.
**Enterprise features that only make sense paid:** central policy service, org-wide allow/deny
rulesets, project-boundary containment enforcement, shell-escape detection inside sandboxed code,
tamper-evident audit trail, per-team reporting. Gate it with the ELv2 license key (see §6).
**Effort:** medium. The engine exists and is tested (`tests/security/`, `src/security.ts` 889 lines);
the work is the control plane.
### P4 — `headroom-sandbox`: Think-in-Code execution
**What:** `executor.ts` exposed as a Headroom MCP tool (`headroom_execute`), 12 languages,
stdout-only.
**Why:** this is the mechanism behind context-mode's largest measured savings —
`ctx_execute_file` returns 98% savings across 315 KB of real fixtures (`BENCHMARK.md` Part 1),
versus 82% for index+search (Part 2). Programming the analysis beats compressing the output.
Must ship *with* P3: the shell-escape scanner is what stops the sandbox being an escape hatch.
**Effort:** medium-high. Runtime isolation is the hard part; `headroom` already has a `sandbox` extra
in `pyproject.toml` to build on.
### P5 — `headroom-attribution`: counterfactual savings + per-project cost
**What:** port the *methodology* from `session/analytics.ts``RealBytesStats`,
`ThinkInCodeComparison`, `enumerateAdapterDirs`, `project-attribution.ts` — into Headroom's
`savings_ledger` / `reporting` / `dashboard`.
**Why:** Headroom measures compression deltas (what it squeezed). context-mode measures the
counterfactual (what never entered). Enterprise buyers want the second number, sliced by team and
repo. Do **not** port `pricing.ts``headroom/pricing/*` already does this with litellm resolution.
**Merge, don't port.** `headroom/audit/reads.py` is already a counterfactual measurement tool over
the same Claude Code transcript corpus (see §8). It has the better mechanism taxonomy — identical
repeat, subset containment, write-readback, stale, line-number scaffolding, context residency,
cache-death windows. `analytics.ts` has the multi-host coverage and per-project attribution it
lacks. Combine the two rather than adding a third implementation.
**Effort:** low-medium, mostly a metrics-definition merge.
### Variants (packaging, not code)
- **Headroom No-Proxy Edition** — P1+P2 only, zero API interposition. Sells to buyers who cannot
reroute model traffic and to every subscription-auth user. Removes the single biggest deployment
blocker Headroom has.
- **Headroom Admission Control (Enterprise)** — P2+P3+P4 with a central policy plane and fleet
enrollment across 18 hosts. Positioned as AI-agent DLP/governance, not token savings.
- **Headroom Fleet** — P5 + `enumerateAdapterDirs` for org-wide rollout state and cost reporting.
---
## 5. Evidence base
context-mode's `BENCHMARK.md`: 21 scenarios, 376 KB raw → 16.5 KB context, **96% overall**, all
fixtures captured from real tool invocations (Context7, Playwright, `gh`, vitest, tsc, nginx logs,
`git log`, analytics CSV) rather than synthetic. Honest about its weak cases — 13% on a 0.4 KB
Playwright network dump, and Part 2 openly explains why index+search only reaches 50-93% (it returns
exact code blocks rather than summaries, by design).
Test suite: 125 tests across executor/store/MCP-integration/ecosystem, plus 45 test dirs in `tests/`
covering adapters, security, session, hooks, analytics.
That's a defensible enough evidence base to reuse in Headroom's own materials, and the fixture corpus
itself is reusable for Headroom's `benchmarks/`.
---
## 6. Blockers — resolve these before writing code
**1. License incompatibility (hard blocker).**
context-mode is **Elastic License 2.0**, "Copyright 2026 Mert Koseoglu". Headroom is
**Apache-2.0**, "Copyright 2025 Headroom Contributors".
- ELv2 code **cannot** be merged into the Apache-2.0 core. Not a technicality — it would relicense
Headroom's core.
- ELv2 forbids providing the software "to third parties as a hosted or managed service." That
directly constrains `headroom-managed/`.
- Different copyright holders means this needs an **IP arrangement between entities**, not an
engineering decision.
The good news: Headroom's plugin architecture is exactly the boundary that makes this tractable.
A separate package with its own `pyproject.toml` and its own `LICENSE`, registered on an entry
point — the `plugins/headroom-oauth2/` shape — can carry ELv2 while core stays Apache-2.0. ELv2 is
also the *right* license for a license-key-gated enterprise tier; it explicitly contemplates one.
Recommendation: any context-mode-derived code ships as separately-licensed plugin packages under
`plugins/`, never vendored into `headroom/`. Get the IP arrangement in writing first.
**2. Realignment collision.**
Phases AI are ~40 PRs / 813 weeks and include deleting ~25K LOC. Do not open a new integration
front mid-Phase-B. P1 (`headroom.memory_text` / `ccr_backend`) is the exception — it *serves* Phase
B's "CCR hardens: persistent backend" goal rather than competing with it.
**3. Phase H direction.**
Python proxy code is being retired. Write nothing new in `headroom/proxy/`. Target the surviving
layers: installers, memory writers, CLI wrappers, and Rust.
---
## 7. Sequencing
| Order | Item | Gate |
|---|---|---|
| 0 | IP/licensing arrangement | before any code |
| 1 | P1 `headroom-recall` — FTS5 store on `memory_text`/`ccr_backend` | lands inside Phase B, serves it |
| 2 | P2 `headroom-admission` — 18-host hook layer under `plugins/` | after Phase A stabilizes |
| 3 | Variant: **No-Proxy Edition** = P1+P2 | as soon as P2 works on 3+ hosts |
| 4 | P3 `headroom-policy` (Enterprise, ELv2, key-gated) | after P2 |
| 5 | P4 `headroom-sandbox` | with P3, never before |
| 6 | P5 `headroom-attribution` | opportunistic |
---
## 8. Follow-up verification
All four items flagged as open in the first pass are now resolved.
**`headroom-managed/` is the SaaS arm, and it is unlicensed.**
`headroom-managed/pyproject.toml`: `name = "headroom-managed"`, `description = "Headroom SaaS
Platform - Managed context window optimization"`, `version = 0.1.0`. It has `app/auth.py`,
`app/middleware/`, `app/routes/`, `app/services/`, `app/models.py`, alembic migrations, and a
`pilot/`. There is **no `license` field and no LICENSE file** — i.e. proprietary by default.
This *sharpens* the §6 blocker rather than easing it. ELv2 forbids providing the software "to third
parties as a hosted or managed service." The product whose name is literally *Managed* is the one
place context-mode-derived code cannot go without an explicit commercial grant from the copyright
holder. Plan the plugin boundary so that `headroom-managed` consumes only Apache-2.0 core
interfaces, never ELv2 implementations.
**`headroom/audit/reads.py` does not overlap P3 — and it independently validates the whole thesis.**
It is a *measurement* tool, not an audit trail: it streams Claude Code `*.jsonl` transcripts to size
"the addressable bytes for each Read compression mechanism... so defaults are set from traffic, not
theory." No policy, no tamper-evidence. P3's audit trail remains a gap.
Two lines in its docstring are the most useful corroboration in either repo:
- *"context residency — how many assistant turns each Read stays in context (the multiplier on its
prefix-cache read cost; **the case for compress-before-cache-entry**)"* — Headroom is already
arguing, from its own traffic, for moving earlier in the pipeline. context-mode is the terminus of
that argument: compress before **context** entry, not merely before cache entry.
- *"identical repeat — a dedup mechanism for this was prototyped and removed: it measured 0.1% of
Read bytes on real traffic."* — Headroom has already empirically established that
message-history-level dedup is worthless. The addressable bytes are at the tool boundary, not in
history. That is the same conclusion the realignment reached from the cache side, arrived at
independently from the traffic side.
It *does* overlap **P5**`audit/reads.py` and context-mode's `session/analytics.ts` are two
independent implementations of counterfactual measurement over the same transcript corpus. Merge
them rather than porting; `audit/reads.py` has the better mechanism taxonomy, `analytics.ts` has
multi-host coverage and per-project attribution.
**No plugin-authoring docs exist.** `docs/` is a Next.js site (`app/`, `content/`, `components/`);
`wiki/` has nothing on extension authoring (only `macos-deployment.md` matched). `plugins/headroom-oauth2/SPEC.md`
remains the de-facto authoring reference — which means whichever plugin lands first sets the house
style. Worth writing the authoring doc as part of P1.
**Headroom publishes no benchmark results.** `benchmarks/` is 29 runner scripts with no committed
results artifacts, so no like-for-like number exists to compare against context-mode's 96%. The
comparison has to be run. The harness is there and is unusually strong on exactly the axis that
matters: `prefix_cache_benchmark.py`, `cache_bust_trace_report.py`, `cache_validation_bundle.py`,
`synthetic_token_cache_bust_report.py`, `proxy_mode_benchmark.py`, `agent_cost_benchmark.py`,
`real_world_agent_benchmark.py`. Use it to *prove* the §1 cache-safety claim empirically rather than
asserting it — a measured "zero cache-bust events" result is the strongest possible artifact for the
No-Proxy Edition.
**Bonus finding — the platform axes are orthogonal.**
`docs/platform-feature-matrix.json` (schema v1, updated 2026-07-06) tracks coverage across
`["linux", "macos", "windows"]` — Headroom's platform axis is **operating system**. context-mode's
platform axis is **agent host** (18 of them). Headroom tracks no host-coverage matrix at all. P2
therefore fills a dimension that does not currently exist in Headroom's own feature accounting,
which also means it needs a second matrix rather than new rows in this one.
*Process note:* six subagents were dispatched across this analysis and all six stalled at the
600-second watchdog; one reported "Bash is temporarily unavailable" before dying, so the failures
were tool-layer, not analytical. Every finding in this document was verified directly.

View file

@ -3,7 +3,7 @@
The Headroom Rust proxy exposes Prometheus-format metrics on the
`/metrics` endpoint of every running proxy instance. The metric
catalogue below covers Phase D (Bedrock route instrumentation) and
Phase G PR-G3 (per-invocation RTK + proxy-wide observability).
Phase G PR-G3 (proxy-wide observability).
All metric names + label keys are constants in
`crates/headroom-proxy/src/observability/metric_names.rs`, so any
@ -59,17 +59,6 @@ intentional byte mutations do not trip the alarm.
| `proxy_service_tier_count_total` | Counter | `tier` | Service-tier distribution observed at the proxy. |
| `proxy_response_status_count_total` | Counter | `status` | Terminal status distribution (`completed`, `incomplete`, `failed`, `cancelled`, `in_progress`). |
#### Wrap CLI / RTK (Python-side)
| Name | Type | Labels | Purpose |
|------|------|--------|---------|
| `wrap_rtk_invocations_total` | Counter | `tool` | RTK invocations observed via the wrap-CLI tail. Surfaced via the Python proxy's `/metrics` exporter; the wrap CLI bumps `headroom.cli.wrap_rtk_metrics.record_rtk_invocation(...)`. |
> **C4 remediation:** This counter is Python-side because RTK is
> wrapped by `headroom wrap` (Python CLI) and the wrap-side tail
> is the natural emit site. The Rust proxy previously held a dead
> counter for this metric; that has been removed.
#### Image log redaction (Python-side)
| Name | Type | Labels | Purpose |
@ -141,9 +130,6 @@ sum by (strategy) (rate(proxy_compression_rejected_by_token_check_total{strategy
# Upstream rate-limit headroom (smaller = closer to throttle).
proxy_rate_limit_remaining_tokens{provider="anthropic"}
# RTK invocation rate (Python-side).
sum by (tool) (rate(wrap_rtk_invocations_total{tool!="__init__"}[5m]))
# Image-redaction rate (Python-side).
rate(proxy_image_generation_call_log_redacted_total[5m])
```
@ -262,13 +248,24 @@ Every label vocabulary is bounded by code, not customer input:
- `tool` (Python-side `wrap_rtk_invocations_total`): bounded by the
set of tools the wrap CLI rewrites, captured by
`headroom.cli.wrap_rtk_metrics`.
- `model` (Python-side `requests_by_model` /
`_cache_requests_by_model`): unlike the Rust path above, the Python
proxy reads `model` from the request body, so it is client-supplied.
It is bounded at record time by `MAX_DISTINCT_MODELS`
(`headroom.telemetry.context`): once the cap is reached, further
distinct models bucket into the `"other"` sentinel and a one-time
warning is logged, mirroring the `tier` discipline above. The
in-memory dicts and the exported `headroom_requests_by_model` series
can never exceed the cap plus `"other"`.
Every label vocabulary listed above is bounded by code, so no
client-supplied value can drive label cardinality unbounded.
There is no code path where a malicious client can drive label
cardinality unbounded.
## See also
- `docs/rtk-architecture.md` — why RTK lives wrap-side, not proxy-side.
- `crates/headroom-proxy/src/observability/` — implementation.
- `REALIGNMENT/09-phase-G-rtk-observability.md` — spec.
- `REALIGNMENT/10-phase-H-python-retirement.md` — H1 acceptance gate.

460
docs/package-lock.json generated
View file

@ -19,7 +19,7 @@
"fumadocs-typescript": "^5.3.0",
"fumadocs-ui": "16.11.5",
"lucide-react": "^1.7.0",
"next": "16.2.10",
"next": "16.3.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"recharts": "^3.9.2",
@ -35,7 +35,7 @@
"@types/react-dom": "^19.2.3",
"ai": "^6.0.149",
"openai": "^6.47.0",
"postcss": "^8.5.19",
"postcss": "^8.5.26",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
@ -199,9 +199,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -694,6 +694,8 @@
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
@ -701,9 +703,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@ -713,19 +715,19 @@
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@ -735,19 +737,38 @@
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@ -761,9 +782,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@ -777,12 +798,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -793,10 +817,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -807,12 +836,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -823,12 +855,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -839,12 +874,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -855,12 +893,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -871,10 +912,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -885,12 +931,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -901,200 +950,244 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@ -1104,16 +1197,16 @@
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@ -1123,16 +1216,16 @@
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@ -1142,7 +1235,7 @@
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@ -1233,15 +1326,15 @@
}
},
"node_modules/@next/env": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
"integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
"integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
"cpu": [
"arm64"
],
@ -1255,9 +1348,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
"integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
"cpu": [
"x64"
],
@ -1271,9 +1364,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
"integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
"cpu": [
"arm64"
],
@ -1290,9 +1383,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
"integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
"cpu": [
"arm64"
],
@ -1309,9 +1402,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
"integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
"cpu": [
"x64"
],
@ -1328,9 +1421,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
"integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
"cpu": [
"x64"
],
@ -1347,9 +1440,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
"integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
"cpu": [
"arm64"
],
@ -1363,9 +1456,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
"integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
"cpu": [
"x64"
],
@ -3078,15 +3171,15 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/caniuse-lite": {
@ -5536,9 +5629,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@ -5554,16 +5647,16 @@
}
},
"node_modules/next": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.10",
"@next/env": "16.3.0",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"postcss": "8.5.23",
"styled-jsx": "5.1.6"
},
"bin": {
@ -5573,15 +5666,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.10",
"@next/swc-darwin-x64": "16.2.10",
"@next/swc-linux-arm64-gnu": "16.2.10",
"@next/swc-linux-arm64-musl": "16.2.10",
"@next/swc-linux-x64-gnu": "16.2.10",
"@next/swc-linux-x64-musl": "16.2.10",
"@next/swc-win32-arm64-msvc": "16.2.10",
"@next/swc-win32-x64-msvc": "16.2.10",
"sharp": "^0.34.5"
"@next/swc-darwin-arm64": "16.3.0",
"@next/swc-darwin-x64": "16.3.0",
"@next/swc-linux-arm64-gnu": "16.3.0",
"@next/swc-linux-arm64-musl": "16.3.0",
"@next/swc-linux-x64-gnu": "16.3.0",
"@next/swc-linux-x64-musl": "16.3.0",
"@next/swc-win32-arm64-msvc": "16.3.0",
"@next/swc-win32-x64-msvc": "16.3.0",
"sharp": "^0.35.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@ -5723,9 +5816,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@ -5742,7 +5835,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -6152,7 +6245,9 @@
}
},
"node_modules/semver": {
"version": "7.7.4",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
@ -6163,46 +6258,53 @@
}
},
"node_modules/sharp": {
"version": "0.34.5",
"hasInstallScript": true,
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
"semver": "^7.8.5"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shiki": {

View file

@ -20,7 +20,7 @@
"fumadocs-typescript": "^5.3.0",
"fumadocs-ui": "16.11.5",
"lucide-react": "^1.7.0",
"next": "16.2.10",
"next": "16.3.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"recharts": "^3.9.2",
@ -36,7 +36,7 @@
"@types/react-dom": "^19.2.3",
"ai": "^6.0.149",
"openai": "^6.47.0",
"postcss": "^8.5.19",
"postcss": "^8.5.26",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
},

View file

@ -1,122 +0,0 @@
# RTK architecture — why wrap-CLI only
**Status:** decided. Locked at Phase G PR-G3 (2026-05).
**Owner:** Headroom realignment.
## TL;DR
**RTK is a wrap-CLI hook, not a proxy-side compressor.** The Headroom
proxy does NOT invoke RTK on tool-result content. Future contributors
who consider moving RTK into the proxy hot path: read this doc first.
## Background
RTK (Realtime Token Kompress) rewrites shell **commands** at exec
time so that a `git diff` or `grep` invocation emits a more
compressed output before the agent ever ingests it. RTK runs in the
wrap-CLI tail — `headroom wrap claude`, `headroom wrap codex`, etc.
— where it installs a `~/.rtk/bin/rtk` shim ahead of the agent CLI
and intercepts shelled-out subprocesses.
It surfaces value in two places:
1. **Tokens saved per invocation** — measured by `rtk gain --format json`.
2. **Tokens saved per session** — aggregated at wrap-session end.
Both signals feed `wrap_rtk_invocations_total` and
`wrap_rtk_tokens_saved_per_session` (registered by the Rust proxy's
observability surface so a single `/metrics` scrape exposes the full
picture).
## Proxy-side RTK was considered and rejected
At Phase G scoping, three reviewers floated the idea of invoking
RTK on the **proxy** side: when a `tool_result` block flows
upstream, dispatch it through RTK to shrink the content before it
hits the model.
**Decision: rejected.** Three load-bearing reasons.
### 1. Cache hot zone risk
The proxy's Phase B cache-safety contract pins `tool_result`
content as part of the cache hot zone. Compression there bursts
the prompt cache because the rewritten bytes diverge from the
canonical wire bytes the upstream cached. Phase B PR-B2 → PR-B7
spent ~3000 LOC carving the live-zone-only surface specifically
to prevent this class of cache-invalidation. Inserting RTK
proxy-side would re-introduce it.
### 2. Parallel implementation with `log_compressor.rs`
The Rust proxy already has a `crates/headroom-core/src/transforms/log_compressor.rs`
that compresses **tool output text** in the live zone. It uses the
same heuristics RTK uses (whitespace de-dup, line de-dup,
file-listing collapse) but invoked at the proxy's per-block
dispatcher rather than at the shell exec boundary. Adding RTK
proxy-side would mean two implementations of the same compression
in the same hot path; "no silent fallbacks, no parallel impls" is
explicit project policy.
### 3. Command-rewrite vs output-rewrite — different value propositions
RTK rewrites **commands** before they execute. The
`git log --oneline` you typed becomes `git log --oneline -n 50`
because RTK has learned that the first 50 commits are usually
enough context. That's a fundamentally different mechanism from
compressing the **output** of an unmodified command. A proxy-side
invocation would skip the command-rewrite half — the half that
generates the largest savings on heavy shell workloads — and only
catch the output side, which is already covered by
`log_compressor` and `code_compressor`.
## What the proxy does provide
Per Phase G PR-G3, the proxy exposes RTK-derived metrics via its
registry:
- `wrap_rtk_invocations_total{tool}` — driven by the wrap-CLI
polling `rtk gain --format json` and incrementing the registered
counter by the delta since last poll.
- `wrap_rtk_tokens_saved_per_session` — emitted at wrap-session
close.
This keeps the operator dashboard single-pane-of-glass without
re-implementing RTK inside the proxy.
## What the wrap CLI does
Every `headroom wrap <agent>` subcommand:
1. Ensures the RTK binary is installed via `_ensure_rtk_binary()`.
2. Injects the `<!-- headroom:rtk-instructions -->` block into the
agent's instruction file (e.g. `AGENTS.md`, `.cursorrules`).
3. Spawns the proxy and the agent CLI side-by-side.
4. Polls `rtk gain --format json` on a 5-second memoization window
and feeds the delta into the proxy's metric registry.
See `headroom/cli/wrap/` for the per-agent shims.
## Re-litigation policy
A change to this architecture should:
1. Quote the live-zone-only contract from
`REALIGNMENT/04-phase-B-live-zone.md` and explain why the
cache-burst risk is acceptable.
2. Show measurements (not estimates) that proxy-side RTK adds value
beyond `log_compressor.rs` on real production traffic.
3. Have an exit ramp: a CLI flag to disable proxy-side RTK without
reverting the wrap-CLI integration.
Without all three, treat the proposal as a regression and link this
doc.
## References
- `REALIGNMENT/09-phase-G-rtk-observability.md` — Phase G plan.
- `REALIGNMENT/04-phase-B-live-zone.md` — cache hot-zone contract.
- `headroom/cli/wrap/` — wrap-CLI implementation.
- `crates/headroom-core/src/transforms/log_compressor.rs` — the
proxy-side log compressor RTK would parallel.
- 2026-05-01 user direction message archived in
`project_compression_realignment_2026_05` memory note.

Some files were not shown because too many files have changed in this diff Show more