mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/headroom-proxy/src/config.rs
This commit is contained in:
commit
b352d5eed4
557 changed files with 48789 additions and 15559 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
32
.github/act/pr-governance-valid.json
vendored
32
.github/act/pr-governance-valid.json
vendored
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -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"
|
||||
|
|
|
|||
BIN
.github/pr-images/issue-2552-windows-fallback-verification.png
vendored
Normal file
BIN
.github/pr-images/issue-2552-windows-fallback-verification.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -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)"
|
||||
|
|
|
|||
69
.github/workflows/docker.yml
vendored
69
.github/workflows/docker.yml
vendored
|
|
@ -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}" \
|
||||
|
|
|
|||
114
.github/workflows/docs.yml
vendored
114
.github/workflows/docs.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
5
.github/workflows/opencode-plugin.yml
vendored
5
.github/workflows/opencode-plugin.yml
vendored
|
|
@ -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; }
|
||||
|
|
|
|||
87
.github/workflows/release-metadata-sync.yml
vendored
Normal file
87
.github/workflows/release-metadata-sync.yml
vendored
Normal 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}"
|
||||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
1
.github/workflows/wrap-e2e.yml
vendored
1
.github/workflows/wrap-e2e.yml
vendored
|
|
@ -9,7 +9,6 @@ on:
|
|||
paths:
|
||||
- 'headroom/cli/**'
|
||||
- 'headroom/providers/**'
|
||||
- 'headroom/rtk/**'
|
||||
- 'crates/**'
|
||||
- 'docker/**'
|
||||
- 'Dockerfile'
|
||||
|
|
|
|||
1
.github/workflows/wrap-native-e2e.yml
vendored
1
.github/workflows/wrap-native-e2e.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
".": "0.32.0"
|
||||
".": "0.35.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
291
CHANGELOG.md
291
CHANGELOG.md
|
|
@ -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 <<ccr:...>> 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 <memory> 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 <system-reminder> 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/<project> 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->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 <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
789
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
14
NOTICE
|
|
@ -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
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 1–3 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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
444
crates/headroom-core/src/rollout.rs
Normal file
444
crates/headroom-core/src/rollout.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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): API -> 2, vs plain "API" -> 1
|
||||
assert_eq!(est.count_text("API"), 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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)),
|
||||
|
|
|
|||
620
crates/headroom-proxy/src/cache_stabilization/beta_sticky.rs
Normal file
620
crates/headroom-proxy/src/cache_stabilization/beta_sticky.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(|| {
|
||||
|
|
|
|||
|
|
@ -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 ----------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
529
crates/headroom-proxy/tests/integration_beta_header_sticky.rs
Normal file
529
crates/headroom-proxy/tests/integration_beta_header_sticky.rs
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
BIN
dashboard-cache-ttl-main.png
Normal file
BIN
dashboard-cache-ttl-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
2
deploy/beacon/.gitignore
vendored
Normal file
2
deploy/beacon/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# wrangler local state, caches, and account info — never commit
|
||||
.wrangler/
|
||||
5
deploy/beacon/package.json
Normal file
5
deploy/beacon/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "headroom-beacon",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
103
deploy/beacon/query.sh
Executable file
103
deploy/beacon/query.sh
Executable 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
|
||||
312
deploy/beacon/sample-event.json
Normal file
312
deploy/beacon/sample-event.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
214
deploy/beacon/test-rollup.mjs
Normal file
214
deploy/beacon/test-rollup.mjs
Normal 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
361
deploy/beacon/worker.js
Normal 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 });
|
||||
},
|
||||
};
|
||||
62
deploy/beacon/wrangler.toml
Normal file
62
deploy/beacon/wrangler.toml
Normal 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>` | — |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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---",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
233
docs/content/docs/opencode-deepseek.mdx
Normal file
233
docs/content/docs/opencode-deepseek.mdx
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
174
docs/content/docs/runtime-rollouts.mdx
Normal file
174
docs/content/docs/runtime-rollouts.mdx
Normal 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.
|
||||
127
docs/content/docs/vscode-claude-code.mdx
Normal file
127
docs/content/docs/vscode-claude-code.mdx
Normal 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.
|
||||
163
docs/content/docs/vscode-copilot.mdx
Normal file
163
docs/content/docs/vscode-copilot.mdx
Normal 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.
|
||||
378
docs/context-mode-integration-analysis.md
Normal file
378
docs/context-mode-integration-analysis.md
Normal 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 A–I are ~40 PRs / 8–13 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.
|
||||
|
|
@ -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
460
docs/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue