Merge remote-tracking branch 'origin/main' into codex/pr2374-refresh

# Conflicts:
#	tests/test_dashboard_cache_ttl_playwright.py
This commit is contained in:
Jerrett Davis 2026-08-14 16:35:58 -05:00
commit 6be5e6878c
347 changed files with 28636 additions and 2222 deletions

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.34.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.34.0",
"version": "0.35.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

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

View file

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

View file

@ -1,19 +1,13 @@
{
"action": "ready_for_review",
"number": 42,
"pull_request": {
"number": 42,
"draft": false,
"title": "feat: add PR governance",
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\nCloses #123\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n- Added a commit-msg hook that runs commitlint locally.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n- [x] Manual testing performed\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR with an incomplete template, then fixed the body.\n- Observed result: The governance check failed until the template and readiness boxes were complete.\n- Not tested: Repository-level automatic Copilot rulesets.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
"user": {
"login": "octocat"
},
"base": {
"sha": "dff6a199"
}
},
"repository": {
"full_name": "JerrettDavis/headroom"
}
}
{
"action": "ready_for_review",
"number": 42,
"pull_request": {
"number": 42,
"draft": false,
"title": "feat: add PR governance",
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR and ran governance.\n- Observed result: The check passed with complete facts.\n- Not tested: Repository settings.\n\n## Runtime Rollout Safety\n\n- Rollout-managed feature(s): None.\n- Minimum rollout channel: Stable.\n- Stable/default behavior changed: No.\n- Kill switch / disable path: Not applicable.\n- Unsafe override required: No.\n- Qualification impact: None.\n- Rollback path: Revert the workflow and script changes.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
"user": {"login": "octocat"},
"base": {"sha": "dff6a199"}
},
"repository": {"full_name": "JerrettDavis/headroom"}
}

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.34.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.34.0",
"version": "0.35.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View file

@ -561,7 +561,7 @@ jobs:
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install pytest 'opentelemetry-api>=1.24.0'
- name: Run native installer wrapper tests
run: pytest tests/test_install/test_native_installers.py -q
@ -579,7 +579,7 @@ jobs:
run: |
brew install bash
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest
python -m pip install --retries 10 --timeout 60 pytest 'opentelemetry-api>=1.24.0'
- name: Run native installer wrapper tests
run: |
BASH_PREFIX="$(brew --prefix bash)"

View file

@ -204,10 +204,10 @@ jobs:
- name: Upload digest marker
uses: actions/upload-artifact@v7
with:
# Variant + arch in the artifact name so the manifest job can
# download with `pattern: digests-<variant>-*` to gather all
# arches for one variant. `root` substitutes the empty-string
# variant since GHA artifact names can't end in a hyphen.
# Variant + arch uniquely identify the marker. The manifest job
# downloads both architecture artifacts by exact name; a glob such
# as `digests-code-*` would also match code-nonroot/code-slim.
# `root` substitutes the empty-string variant.
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
@ -220,6 +220,7 @@ jobs:
# tags, and that manifest is what users pull by `:tag`.
docker-manifest:
needs: docker-build
if: ${{ always() }}
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
@ -272,12 +273,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download per-arch digests for this variant
- name: Download amd64 digest for this variant
uses: actions/download-artifact@v8
with:
pattern: digests-${{ matrix.variant.name || 'root' }}-*
name: digests-${{ matrix.variant.name || 'root' }}-amd64
path: ${{ runner.temp }}/digests
- name: Download arm64 digest for this variant
uses: actions/download-artifact@v8
with:
name: digests-${{ matrix.variant.name || 'root' }}-arm64
path: ${{ runner.temp }}/digests
merge-multiple: true
# Same tag rules as the pre-fan-out workflow — preserve every
# tag flavor (semver, ref, sha-prefixed, version-suffixed,
@ -313,6 +319,11 @@ jobs:
echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2
exit 1
fi
digest_count="$(find "${DIGEST_DIR}" -maxdepth 1 -type f | wc -l)"
if [ "${digest_count}" -ne 2 ]; then
echo "ERROR: expected both architecture digests for variant '${{ matrix.variant.name || 'root' }}', found ${digest_count}" >&2
exit 1
fi
digest_refs=()
for f in "${DIGEST_DIR}"/*; do
digest="$(basename "$f")"
@ -382,53 +393,13 @@ jobs:
sleep "$sleep_for"
done
promote-latest:
# Re-push the :latest tag pointing at the root variant *after* every
# variant manifest job has finished, so GHCR's package version
# listing (sorted by created_at) shows the root image with :latest
# at the top instead of whichever variant happened to finish last.
needs: docker-manifest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Normalize image name
id: image-name
run: |
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
- name: Determine image version
id: version
env:
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
version="${MANUAL_VERSION#v}"
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
version="${RELEASE_TAG#v}"
fi
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag root image as :latest
if: steps.version.outputs.version != ''
if: steps.manifest.outputs.index_digest != '' && matrix.variant.name == '' && steps.version.outputs.version != ''
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
VERSION: ${{ steps.version.outputs.version }}
run: |
# Add a unique annotation so the resulting image index manifest gets
# a new digest, which makes GHCR record a fresh package version with
# current timestamp (otherwise the existing root manifest is reused
# and stays where it was in the version listing).
# Add a unique annotation so GHCR records a fresh root package version.
promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker buildx imagetools create \
--annotation "index:io.headroom.promoted-at=${promoted_at}" \

View file

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

View file

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

View file

@ -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.15.22
hooks:
- id: ruff
args: [--fix]

View file

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

View file

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

View file

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

View file

@ -284,6 +284,137 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **code:** fix two `CodeAwareCompressor` AST-reassembly bugs: an exported JS/TS function or class (`export function foo() {`) produced a duplicated `export export` keyword and invalid syntax, because line-based node slicing (used to preserve indentation) pulled in the preceding `export` sibling's text on top of the `export_statement` handler's own prefix reconstruction. Separately, in every supported language, a doc comment immediately above a top-level function, class, or type was detached from its declaration during extraction and re-emitted in a cluster at the end of the compressed output instead of staying attached to what it documents.
- * **proxy:** Buffered upstream responses containing a `server_tool_use` (or any other unrecognized Anthropic content block) no longer turn a fully-generated response into an HTTP 502. `StreamingMixin._response_to_sse` raised `ValueError` on unknown block types after the entire upstream generation had already been buffered, so a slow-but-successful response failed and the client retried the whole multi-minute request. Unknown blocks are now emitted verbatim in `content_block_start` (following the existing redacted_thinking` pattern), so `server_tool_use`, `server_tool_result`, `mcp_tool_use`, and future block types round-trip ([#1806](https://github.com/headroomlabs-ai/headroom/issues/1806)).
## [0.35.0](https://github.com/headroomlabs-ai/headroom/compare/v0.34.0...v0.35.0) (2026-08-12)
### Features
* **beacon:** allowlist the routing summary key ([#2818](https://github.com/headroomlabs-ai/headroom/issues/2818)) ([7940c05](https://github.com/headroomlabs-ai/headroom/commit/7940c05ebf4486c6b9d00984067ae33cedf4dddb))
* **beacon:** hourly R2 compaction, per-strategy savings, and a stack that reports ([#2853](https://github.com/headroomlabs-ai/headroom/issues/2853)) ([e0870ef](https://github.com/headroomlabs-ai/headroom/commit/e0870ef931e5ea6cc6cb52551f5d80cd9e3dc715))
* **cli,pricing:** add CLI extension seam and prompt-cache TTL pricing ([#2802](https://github.com/headroomlabs-ai/headroom/issues/2802)) ([6ec3e34](https://github.com/headroomlabs-ai/headroom/commit/6ec3e3478abf058fe1460f91342bcdadf54a1ba8))
### Bug Fixes
* **anthropic:** strip first-party tool search on custom upstreams ([#2539](https://github.com/headroomlabs-ai/headroom/issues/2539)) ([7f6950b](https://github.com/headroomlabs-ai/headroom/commit/7f6950be34e29304deae0fa5138b852491b092fe))
* **backends/anyllm:** convert Anthropic tools and tool_choice to OpenAI shape ([0d6866b](https://github.com/headroomlabs-ai/headroom/commit/0d6866b91a3777475abd58cd8b63a10cd0621e7f))
* **backends/anyllm:** stream tool_use blocks and map finish_reason on the streaming path ([e4904e2](https://github.com/headroomlabs-ai/headroom/commit/e4904e23a6ba6f5cff2488332946481172446922))
* **backends/litellm:** None-guard core token counts in OpenAI usage block ([#2324](https://github.com/headroomlabs-ai/headroom/issues/2324)) ([12f9f58](https://github.com/headroomlabs-ai/headroom/commit/12f9f58cb3dcfc67af1238424d404d8dd9bad1dd))
* **beacon:** report all-layers savings, not context-compression only ([#2796](https://github.com/headroomlabs-ai/headroom/issues/2796)) ([e9a24f3](https://github.com/headroomlabs-ai/headroom/commit/e9a24f3ec1ffd278b0b3ca547a90942c40c99ec8))
* **beacon:** split session failures by status code ([#2815](https://github.com/headroomlabs-ai/headroom/issues/2815)) ([2954e37](https://github.com/headroomlabs-ai/headroom/commit/2954e37048f8dcffe16e1c37b8f71afb0094a0a2))
* **cache:** bound compression cache bookkeeping ([0ae948c](https://github.com/headroomlabs-ai/headroom/commit/0ae948c1510735df39317bf0861f8a8750cdbf9d))
* **cache:** enforce Anthropic's 1h-before-5m cache_control ordering before forwarding ([#2941](https://github.com/headroomlabs-ai/headroom/issues/2941)) ([3752458](https://github.com/headroomlabs-ai/headroom/commit/3752458022f736c779f7b5a6c2d6d2ef0bc89f72))
* **cache:** mirror client cache_control positions instead of single-marker consolidation ([def3d76](https://github.com/headroomlabs-ai/headroom/commit/def3d76e5ab4665e609b51bfba54dd6d25116925))
* **cache:** stabilize Anthropic block-growing lineages ([#2917](https://github.com/headroomlabs-ai/headroom/issues/2917)) ([1a04c95](https://github.com/headroomlabs-ai/headroom/commit/1a04c957f53ef25ab1209166f425a7876913c4d3))
* **ccr:** avoid injecting tool on chat streaming ([d0c1f5b](https://github.com/headroomlabs-ai/headroom/commit/d0c1f5b8ad68c7a44ed3aaa0fe40e3a656950123))
* **ccr:** preserve exact SQLite TTL boundary ([#2669](https://github.com/headroomlabs-ai/headroom/issues/2669)) ([d0a86d4](https://github.com/headroomlabs-ai/headroom/commit/d0a86d409fab377f9c642d1f3680b6ece7f97b8a))
* **ccr:** report embedded hashes from compress endpoint ([#717](https://github.com/headroomlabs-ai/headroom/issues/717)) ([685ebe4](https://github.com/headroomlabs-ai/headroom/commit/685ebe457d727922ba4057515556a2d2aac0f616))
* **ccr:** resolve &lt;&lt;ccr:...&gt;&gt; markers inline when no retrieve-tool path exists ([#2512](https://github.com/headroomlabs-ai/headroom/issues/2512)) ([ce8ce83](https://github.com/headroomlabs-ai/headroom/commit/ce8ce8313f8cebf060392a62f9adaab18c0df386))
* **ccr:** tolerate null/malformed OpenAI data in response handling ([#2467](https://github.com/headroomlabs-ai/headroom/issues/2467)) ([e583e08](https://github.com/headroomlabs-ai/headroom/commit/e583e082d8dee942229ac6211c742f9c9448a905))
* **ci:** publish latest from the root Docker manifest ([#2252](https://github.com/headroomlabs-ai/headroom/issues/2252)) ([5568d73](https://github.com/headroomlabs-ai/headroom/commit/5568d738afb5e080d8df56e64500026996cbf025))
* **claude:** stop forcing tool search on Foundry ([#2477](https://github.com/headroomlabs-ai/headroom/issues/2477)) ([7981396](https://github.com/headroomlabs-ai/headroom/commit/798139608c0fb5118eb3a7a183b8b2abe92341f1))
* **cli/update:** let install ownership win over bare /.dockerenv so venv installs self-update ([#2830](https://github.com/headroomlabs-ai/headroom/issues/2830)) ([7092b53](https://github.com/headroomlabs-ai/headroom/commit/7092b53c466bf5dbda8a1cda88403d1a4b16deb1))
* **codex:** route alpha search through the Codex backend ([#2538](https://github.com/headroomlabs-ai/headroom/issues/2538)) ([a540eb2](https://github.com/headroomlabs-ai/headroom/commit/a540eb2c61b1a47e5ab8b07ea4a80fee780b6514))
* **content-router:** protect custom-tag blocks before mixed-content section split ([d7bc1e2](https://github.com/headroomlabs-ai/headroom/commit/d7bc1e275f411788abffa2d007db14aa17fd31c5))
* **deps:** bump h2 to 4.4.1 for CVE-2026-71554 ([#2839](https://github.com/headroomlabs-ai/headroom/issues/2839)) ([564e0a8](https://github.com/headroomlabs-ai/headroom/commit/564e0a8d0fe440dff21a6c405c88e05698b3059f))
* **deps:** enforce audited transitive dependency floors ([#2791](https://github.com/headroomlabs-ai/headroom/issues/2791)) ([64e2039](https://github.com/headroomlabs-ai/headroom/commit/64e203931b9810e5a010f063d26d154419016f86))
* **doctor:** flag `ollama launch claude` proxy bypass instead of misdirecting ([#2566](https://github.com/headroomlabs-ai/headroom/issues/2566)) ([7f24d69](https://github.com/headroomlabs-ai/headroom/commit/7f24d695eea00b9bb3265fbaa6629acf0c2ff181))
* emit SSE ping before message_start on Bedrock streaming path (issue [#902](https://github.com/headroomlabs-ai/headroom/issues/902)) ([#1080](https://github.com/headroomlabs-ai/headroom/issues/1080)) ([4dab254](https://github.com/headroomlabs-ai/headroom/commit/4dab254d52914c39ffe13071848604e1771b1bd1))
* **gemini:** resolve native CCR retrieval calls ([#2253](https://github.com/headroomlabs-ai/headroom/issues/2253)) ([2483f57](https://github.com/headroomlabs-ai/headroom/commit/2483f570025763cd9183a93749ea8cf38f1aeb85))
* **health:** label kompress as degraded/optional when not yet loaded ([#2865](https://github.com/headroomlabs-ai/headroom/issues/2865)) ([8949371](https://github.com/headroomlabs-ai/headroom/commit/89493714d2cffdc1f81a8f417ea09891453d7009))
* **image:** decouple routing types from trained_router so importing the compressor doesn't import torch ([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513)) ([#2537](https://github.com/headroomlabs-ai/headroom/issues/2537)) ([d7cf981](https://github.com/headroomlabs-ai/headroom/commit/d7cf981093cf505192a3736dadd0254a120830a1))
* **install/windows:** register persistent-task from S4U hidden XML ([#2453](https://github.com/headroomlabs-ai/headroom/issues/2453)) ([#2459](https://github.com/headroomlabs-ai/headroom/issues/2459)) ([1edaeb8](https://github.com/headroomlabs-ai/headroom/commit/1edaeb8b76f6b872a6c810d404c944caf1a594b2))
* **install:** don't crash the PowerShell installer when $PROFILE is unset ([#2469](https://github.com/headroomlabs-ai/headroom/issues/2469)) ([fc5c4e2](https://github.com/headroomlabs-ai/headroom/commit/fc5c4e239ce32f2b90a6777772a01bdf49c66cb6))
* **install:** trust Docker bridge for dashboard metadata ([e044139](https://github.com/headroomlabs-ai/headroom/commit/e044139001680fd5198147bf373df6f00db32cc7))
* **install:** use --userns=keep-id under Podman so bind-mount writes don't fail ([#2846](https://github.com/headroomlabs-ai/headroom/issues/2846)) ([3488f8d](https://github.com/headroomlabs-ai/headroom/commit/3488f8d4b5fae4eab157e0c4031ccf712bcbcc0d))
* **learn/gemini:** stop double-counting session tokens ([#2230](https://github.com/headroomlabs-ai/headroom/issues/2230)) ([29d8a5e](https://github.com/headroomlabs-ai/headroom/commit/29d8a5e563cf16dbd3a53a1571f4f352e61e1b33))
* **learn/grok:** detect a Windows absolute project path ([#2283](https://github.com/headroomlabs-ai/headroom/issues/2283)) ([e240df2](https://github.com/headroomlabs-ai/headroom/commit/e240df2b698e601324b85956bd93cb304f6030ab))
* **learn:** stop classifying a successful exit code 0 as an error ([#2289](https://github.com/headroomlabs-ai/headroom/issues/2289)) ([a24fe7d](https://github.com/headroomlabs-ai/headroom/commit/a24fe7dcbfe5ab30d0cef631c936e2245c12d123))
* **litellm:** add async_post_call_success_hook to HeadroomCallback ([#1322](https://github.com/headroomlabs-ai/headroom/issues/1322)) ([3107994](https://github.com/headroomlabs-ai/headroom/commit/3107994aed5fd42e713d3c26f3f08121a62b980e))
* **litellm:** don't forward a caller key the target cannot accept ([#2883](https://github.com/headroomlabs-ai/headroom/issues/2883)) ([2f2950a](https://github.com/headroomlabs-ai/headroom/commit/2f2950a626cebf851aac29255e7188fbb1639f5a))
* **memory:** bound the TrafficLearner pending-pattern accumulator (memory leak) ([#2579](https://github.com/headroomlabs-ai/headroom/issues/2579)) ([1f5feff](https://github.com/headroomlabs-ai/headroom/commit/1f5fefffd3e82c73bddd928cfd53334031e807bc))
* **memory:** close DirectMem0 resources ([6596182](https://github.com/headroomlabs-ai/headroom/commit/65961827cf5e90d7b4e7026feb89aac000a73ea3))
* **memory:** close MCP backend on shutdown ([4bd8ecd](https://github.com/headroomlabs-ai/headroom/commit/4bd8ecd1e31475365801791d35630f66f7393553))
* **memory:** don't crash inline memory extraction on a non-object &lt;memory&gt; block ([#2470](https://github.com/headroomlabs-ai/headroom/issues/2470)) ([e00c6ff](https://github.com/headroomlabs-ai/headroom/commit/e00c6ff81ce2003e04042b8f2d1bd6aa3c6e885c))
* **memory:** keep vector metadata in sync ([#2295](https://github.com/headroomlabs-ai/headroom/issues/2295)) ([c471800](https://github.com/headroomlabs-ai/headroom/commit/c471800e8ee22986c308464b02a85da5575f34cc))
* **memory:** make explicit-project and user store keys collision-resistant ([#2231](https://github.com/headroomlabs-ai/headroom/issues/2231)) ([f840d5f](https://github.com/headroomlabs-ai/headroom/commit/f840d5f2fe938432e542c3f71f2218eeecd06b05))
* **memory:** skip &lt;system-reminder&gt; blocks when building the retrieval query ([#2195](https://github.com/headroomlabs-ai/headroom/issues/2195)) ([#2541](https://github.com/headroomlabs-ai/headroom/issues/2541)) ([4e5a67a](https://github.com/headroomlabs-ai/headroom/commit/4e5a67a342be4be659b62c7863a9e72422605788))
* **memory:** sync FTS5 and vector indexes on CLI delete/edit/prune/purge ([fd4628d](https://github.com/headroomlabs-ai/headroom/commit/fd4628d82156c65d4fa22df9513315790a6cd2fb))
* **oauth2:** make repository lint checks pass ([c85abf7](https://github.com/headroomlabs-ai/headroom/commit/c85abf7a87920012e01f0a677f6fbd98c4b08de0))
* **observability:** aggregate tool savings in OTEL ([#2936](https://github.com/headroomlabs-ai/headroom/issues/2936)) ([941c25d](https://github.com/headroomlabs-ai/headroom/commit/941c25d31e6c6e0b436c307cbe212771ff76b45f))
* **onnx:** stop ONNX thread pools from spinning idle cores ([#2495](https://github.com/headroomlabs-ai/headroom/issues/2495)) ([#2540](https://github.com/headroomlabs-ai/headroom/issues/2540)) ([5c561bd](https://github.com/headroomlabs-ai/headroom/commit/5c561bd913ea60fad2c3c53f4b65e679e7d248d0))
* **openai:** skip Responses tool-search deferral for clients that cannot execute it ([#2696](https://github.com/headroomlabs-ai/headroom/issues/2696)) ([54ea28d](https://github.com/headroomlabs-ai/headroom/commit/54ea28d9839a0dcfa4dd0cf4210a4421f03beeff))
* **opencode:** ship the transport hook-shim so wheel installs route Node child traffic ([702dbc5](https://github.com/headroomlabs-ai/headroom/commit/702dbc5902ff184a7c20178958a811beb9c78fa3))
* **providers/anthropic:** don't crash token estimation on null tool_calls ([#2472](https://github.com/headroomlabs-ai/headroom/issues/2472)) ([08466f3](https://github.com/headroomlabs-ai/headroom/commit/08466f3cae4dbb2647dc6f249fe42c4e840600c5))
* **providers/openai:** bound tiktoken vocab loads with the guarded loader ([#2554](https://github.com/headroomlabs-ai/headroom/issues/2554)) ([0805e8e](https://github.com/headroomlabs-ai/headroom/commit/0805e8e410543d75c7ddd3b83dde5eda3bc13144))
* **proxy/anthropic:** inject headroom_retrieve whenever a CCR marker is present, not only for new markers ([#2848](https://github.com/headroomlabs-ai/headroom/issues/2848)) ([3808f60](https://github.com/headroomlabs-ai/headroom/commit/3808f60ca61e84faf3ea8f8e003a6e6c8e9af4da))
* **proxy/anthropic:** None-guard usage token counts on the direct buffered path ([#2434](https://github.com/headroomlabs-ai/headroom/issues/2434)) ([2b5ee7c](https://github.com/headroomlabs-ai/headroom/commit/2b5ee7cde809ca37f6998d9679b1eb2133ab50ca))
* **proxy/anthropic:** run tool-search history repair after turn hooks ([c6f9948](https://github.com/headroomlabs-ai/headroom/commit/c6f99482e1bea024db6014a70c8e6da419543957))
* **proxy/batch:** don't crash an OpenAI batch on a valid-JSON non-object line ([#2316](https://github.com/headroomlabs-ai/headroom/issues/2316)) ([1f2c681](https://github.com/headroomlabs-ai/headroom/commit/1f2c681c0b48150a569277d3ebd5e95709dc7c39))
* **proxy/bedrock:** report uncached input tokens from backend usage, not the live-zone count ([#2318](https://github.com/headroomlabs-ai/headroom/issues/2318)) ([c19e412](https://github.com/headroomlabs-ai/headroom/commit/c19e412b3356d80dece001887d4ff48b6fd5150b))
* **proxy/gemini:** keep streaming-parity baseline so eligible_pct can't exceed 100 ([#2824](https://github.com/headroomlabs-ai/headroom/issues/2824)) ([b97c7c6](https://github.com/headroomlabs-ai/headroom/commit/b97c7c6e99eac84df49c7a7e5f21dedb298716fe))
* **proxy/metrics:** cap client-supplied model label cardinality ([#2480](https://github.com/headroomlabs-ai/headroom/issues/2480)) ([e24a7e6](https://github.com/headroomlabs-ai/headroom/commit/e24a7e66b95fa908c4ea6fd079809ece7692e6b2))
* **proxy/metrics:** escape label values in the Prometheus export ([#2463](https://github.com/headroomlabs-ai/headroom/issues/2463)) ([6a53861](https://github.com/headroomlabs-ai/headroom/commit/6a53861063c3839e698bbec7194517bdfd851c38))
* **proxy/openai:** don't crash the Responses memory tool loops on null arguments ([#2273](https://github.com/headroomlabs-ai/headroom/issues/2273)) ([a30db2c](https://github.com/headroomlabs-ai/headroom/commit/a30db2cae49b4ef03ebbd404ec1fc6c4f5f2404d))
* **proxy/openai:** feed Codex WS traffic into the traffic learner ([#2334](https://github.com/headroomlabs-ai/headroom/issues/2334)) ([f669149](https://github.com/headroomlabs-ai/headroom/commit/f6691497692869b7067438597421ff12aace6bf4))
* **proxy/openai:** run response hooks on Responses, and bill their re-drives ([#2872](https://github.com/headroomlabs-ai/headroom/issues/2872)) ([675d13f](https://github.com/headroomlabs-ai/headroom/commit/675d13f08d42455c8fa17bda878c1a11b905cee4))
* **proxy:** allow settings routes for trusted gateway/dashboard clients ([#2491](https://github.com/headroomlabs-ai/headroom/issues/2491)) ([a5b0a8f](https://github.com/headroomlabs-ai/headroom/commit/a5b0a8f4cc54d68afcf371a422b3a4a9635b7e7f))
* **proxy:** cache litellm model resolution to stop repeated Provider List spam ([99f07e7](https://github.com/headroomlabs-ai/headroom/commit/99f07e7bbdded9dadc70e35ee6ab025279d1aa22))
* **proxy:** cancel periodic TOIN task on shutdown ([739fdef](https://github.com/headroomlabs-ai/headroom/commit/739fdef423fa8cbc82537481c875d4570b0ecad4))
* **proxy:** close the upstream stream when a streaming body is never consumed ([0951663](https://github.com/headroomlabs-ai/headroom/commit/09516635621caccf7e3db4f537eb49ea49b8a453))
* **proxy:** compress cache-mode cold starts and tag prefix-mismatch passthrough ([#2365](https://github.com/headroomlabs-ai/headroom/issues/2365)) ([aaeba0a](https://github.com/headroomlabs-ai/headroom/commit/aaeba0a319f12b98cad3bfcf1cf991b694b946bf))
* **proxy:** emit request log timestamps in UTC ([620028f](https://github.com/headroomlabs-ai/headroom/commit/620028fa18843622d3e454bd40fb91a93e607dbf))
* **proxy:** enable tool search by default and repair poisoned transcripts ([#2807](https://github.com/headroomlabs-ai/headroom/issues/2807)) ([0237cbf](https://github.com/headroomlabs-ai/headroom/commit/0237cbffbbc456ad8a7398005602d76881862d99))
* **proxy:** gate mid-turn message coalescing to Claude Code clients ([#1643](https://github.com/headroomlabs-ai/headroom/issues/1643)) ([a4bd2e6](https://github.com/headroomlabs-ai/headroom/commit/a4bd2e62a5bb73f15b3b12e979c69e2b555bee10))
* **proxy:** give each Codex /v1/responses WS turn a unique request_id ([#2164](https://github.com/headroomlabs-ai/headroom/issues/2164)) ([d02df10](https://github.com/headroomlabs-ai/headroom/commit/d02df1075894b414d60626aca2bbcadd7a3577a0))
* **proxy:** graceful shutdown and reliable Ctrl+C exit ([#621](https://github.com/headroomlabs-ai/headroom/issues/621)) ([17cdb18](https://github.com/headroomlabs-ai/headroom/commit/17cdb185bc79d8cfec104e781a7e555af3ef11e1))
* **proxy:** guard telemetry and TOIN endpoints ([cde1513](https://github.com/headroomlabs-ai/headroom/commit/cde1513c91b6c6c240869bc5660f4b8966197bbc))
* **proxy:** include tool_search_deferral savings in the savings ledger ([12149f7](https://github.com/headroomlabs-ai/headroom/commit/12149f74466c08b69be8d5fe751425be63c2fda4))
* **proxy:** pass through cross-region prefixed Bedrock model IDs directly ([#2330](https://github.com/headroomlabs-ai/headroom/issues/2330)) ([64cb46e](https://github.com/headroomlabs-ai/headroom/commit/64cb46e24bf7b223ea71b14b6f5e86e78fa7ac45))
* **proxy:** port session-sticky beta headers to the Rust proxy ([#2381](https://github.com/headroomlabs-ai/headroom/issues/2381)) ([f6398a6](https://github.com/headroomlabs-ai/headroom/commit/f6398a64768a095b722a5fb0b2445c7953dee1c6))
* **proxy:** preserve merged session and quarantine contracts ([#2943](https://github.com/headroomlabs-ai/headroom/issues/2943)) ([039cd24](https://github.com/headroomlabs-ai/headroom/commit/039cd2431aaec7d59fefaf7e97aeda1fd7ab3afa))
* **proxy:** preserve signed Anthropic thinking blocks on outbound re-serialize ([#2254](https://github.com/headroomlabs-ai/headroom/issues/2254)) ([dc163bc](https://github.com/headroomlabs-ai/headroom/commit/dc163bcd1cba4cd8898f23286eb1365fcf6e0356))
* **proxy:** stop discarding compressed Codex WS later-frame payloads ([#2823](https://github.com/headroomlabs-ai/headroom/issues/2823)) ([4ec416d](https://github.com/headroomlabs-ai/headroom/commit/4ec416df8899036544e679f561f1cf921f3da0dd))
* **proxy:** time-cap the compression timeout-debt quarantine ([#2360](https://github.com/headroomlabs-ai/headroom/issues/2360)) ([#2412](https://github.com/headroomlabs-ai/headroom/issues/2412)) ([c5a08d2](https://github.com/headroomlabs-ai/headroom/commit/c5a08d22e05a7dd2b929f3cca76ee3fb42f122db))
* **proxy:** unwrap Hermes tool_call bridge in tool name map ([#2717](https://github.com/headroomlabs-ai/headroom/issues/2717)) ([a97b824](https://github.com/headroomlabs-ai/headroom/commit/a97b82413bdc86655c064417ed4628ff4d9d7c9d))
* publish headroom-opencode in release workflow ([#2372](https://github.com/headroomlabs-ai/headroom/issues/2372)) ([7859154](https://github.com/headroomlabs-ai/headroom/commit/78591545ceb8303fdf9b93cd5ff02b626df97d2b))
* **settings:** accept documented HEADROOM_* env names as settings keys ([#2833](https://github.com/headroomlabs-ai/headroom/issues/2833)) ([de9e052](https://github.com/headroomlabs-ai/headroom/commit/de9e0523dad47b700062464adecd60f82547f332))
* **subscription:** dedup transcript usage by message id ([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340) token inflation) ([#2408](https://github.com/headroomlabs-ai/headroom/issues/2408)) ([74275b7](https://github.com/headroomlabs-ai/headroom/commit/74275b7c3e2b39be5198f9efa35057a5e026e665))
* **toin:** bound private query and pattern retention ([8cd1380](https://github.com/headroomlabs-ai/headroom/commit/8cd138039edbfc295080ec474325d527fb3aedf3))
* **tokenizer:** coerce non-string tool_call fields before counting ([#2801](https://github.com/headroomlabs-ai/headroom/issues/2801)) ([b6f9877](https://github.com/headroomlabs-ai/headroom/commit/b6f9877c78b3fa3b1d705426bd27d74be77f4fa0))
* **tokenizer:** price CJK in the Rust fixed-ratio estimator (Python parity) ([#2260](https://github.com/headroomlabs-ai/headroom/issues/2260)) ([6840153](https://github.com/headroomlabs-ai/headroom/commit/6840153473caa0d61e982215e16a8cf54b0b6cc7))
* **transforms/adaptive-sizer:** honor max_k on small-input fast path ([#2319](https://github.com/headroomlabs-ai/headroom/issues/2319)) ([8a90523](https://github.com/headroomlabs-ai/headroom/commit/8a905232091d993fac9e19a59bc449f201d4cdf3))
* **transforms/smart_crusher:** don't crash on a tool call with a null function ([#2232](https://github.com/headroomlabs-ai/headroom/issues/2232)) ([3bb02f8](https://github.com/headroomlabs-ai/headroom/commit/3bb02f8f75f12cf8258a5b1c2a7fbdc190f9d074))
* Vertex model pricing shows $0.00 for versioned model names and vertex:anthropic provider ([#2517](https://github.com/headroomlabs-ai/headroom/issues/2517)) ([eb5b5e4](https://github.com/headroomlabs-ai/headroom/commit/eb5b5e41988f5c27d29ae8ae3e5fe74e56493b8c))
* **wrap/claude:** keep --1m effective when an explicit --model is passed through ([c093bf1](https://github.com/headroomlabs-ai/headroom/commit/c093bf11eb5f356f71367ebb7b56ae3c2b434a12))
* **wrap/opencode:** verify the opencode binary before mutating config ([ae38486](https://github.com/headroomlabs-ai/headroom/commit/ae384862a4950cec057103e9daf75e74107640df))
* **wrap/serena:** install Serena from the serena-agent PyPI wheel, not the git source ([d7b25ae](https://github.com/headroomlabs-ai/headroom/commit/d7b25ae3bb3364cde4931509ecb65e32085e5b09))
* **wrap:** honor Copilot OAuth wire-api override and model default ([#2387](https://github.com/headroomlabs-ai/headroom/issues/2387)) ([1db6d88](https://github.com/headroomlabs-ai/headroom/commit/1db6d88ab4ea25654b8277358902b7df700db6b4))
* **wrap:** serialize shared proxy startup ([#2946](https://github.com/headroomlabs-ai/headroom/issues/2946)) ([e540d64](https://github.com/headroomlabs-ai/headroom/commit/e540d64febf27f2e7997d3a1a1d89478cc1ef658))
* **wrap:** stop the launch cwd from shadowing the installed package in the proxy subprocess ([#2843](https://github.com/headroomlabs-ai/headroom/issues/2843)) ([c49be26](https://github.com/headroomlabs-ai/headroom/commit/c49be269a18446779cd8a048caaa7f0ba3a3b48b))
### Performance Improvements
* cut hot-path latency 27% (token-count memo, startup preloads, JSON scan memo) ([#2838](https://github.com/headroomlabs-ai/headroom/issues/2838)) ([53af90d](https://github.com/headroomlabs-ai/headroom/commit/53af90d68c723f644a5a41dd273a606117109866))
* **proxy:** bound upstream calls and hot-path costs ([#2852](https://github.com/headroomlabs-ai/headroom/issues/2852)) ([f624d3a](https://github.com/headroomlabs-ai/headroom/commit/f624d3a00ac271db7947443ddeb0c8bc2e93d3eb))
* **subscription:** skip transcripts older than the window in compute_window_tokens ([#2861](https://github.com/headroomlabs-ai/headroom/issues/2861)) ([91d6bf3](https://github.com/headroomlabs-ai/headroom/commit/91d6bf33cde777b541375fb182d4479fdd78f81b))
### Dependencies
* bump brace-expansion from 5.0.7 to 5.0.9 in /docs ([#2751](https://github.com/headroomlabs-ai/headroom/issues/2751)) ([56ee57b](https://github.com/headroomlabs-ai/headroom/commit/56ee57be98bf109f0a46de522724ef169a4bc51c))
* bump bytesize from 1.3.3 to 2.4.2 ([#2286](https://github.com/headroomlabs-ai/headroom/issues/2286)) ([6448545](https://github.com/headroomlabs-ai/headroom/commit/6448545a7f5a1dee88bce6f0830bdbfd1c99c617))
* bump hf-hub from 0.4.3 to 0.5.0 ([#2285](https://github.com/headroomlabs-ai/headroom/issues/2285)) ([4925bf6](https://github.com/headroomlabs-ai/headroom/commit/4925bf6a829735977bab5000b469c3edb19c75b1))
* bump next from 16.2.10 to 16.3.0 in /docs ([#2750](https://github.com/headroomlabs-ai/headroom/issues/2750)) ([0fd0b99](https://github.com/headroomlabs-ai/headroom/commit/0fd0b996a4b58a166491b145f4d3885c21b27cc0))
* bump postcss from 8.5.19 to 8.5.25 in /plugins/openclaw ([#2749](https://github.com/headroomlabs-ai/headroom/issues/2749)) ([cd60ee9](https://github.com/headroomlabs-ai/headroom/commit/cd60ee9ae886b32ba5da3203e35bb6b088031fd3))
* bump postcss from 8.5.19 to 8.5.25 in /plugins/opencode ([#2748](https://github.com/headroomlabs-ai/headroom/issues/2748)) ([ff4e016](https://github.com/headroomlabs-ai/headroom/commit/ff4e0167bbccbd4ae51bf23ddec144e61c94cd68))
* bump postcss from 8.5.19 to 8.5.25 in /sdk/typescript ([#2747](https://github.com/headroomlabs-ai/headroom/issues/2747)) ([267c2bd](https://github.com/headroomlabs-ai/headroom/commit/267c2bdcb56e132b2dd9c065dab3498dbf730ca3))
* bump postcss from 8.5.19 to 8.5.26 in /docs ([#2881](https://github.com/headroomlabs-ai/headroom/issues/2881)) ([e6e5826](https://github.com/headroomlabs-ai/headroom/commit/e6e5826423a0a700a8c544ce2c8cbcdef694160e))
* bump ruff from 0.15.17 to 0.15.22 in the pip-minor-patch group ([#2501](https://github.com/headroomlabs-ai/headroom/issues/2501)) ([ecf130d](https://github.com/headroomlabs-ai/headroom/commit/ecf130d3ac6fb864098cb93fafd2621ae3ac7e12))
* bump rusqlite from 0.32.1 to 0.40.1 ([#2287](https://github.com/headroomlabs-ai/headroom/issues/2287)) ([522faa1](https://github.com/headroomlabs-ai/headroom/commit/522faa1a59aa94e4adfd4a4afe0202d1126e187d))
* bump the cargo-minor-patch group across 1 directory with 22 updates ([#2916](https://github.com/headroomlabs-ai/headroom/issues/2916)) ([148d860](https://github.com/headroomlabs-ai/headroom/commit/148d8605e2087f3c8d6a3fa4b8d248ad2da5858f))
## [0.34.0](https://github.com/headroomlabs-ai/headroom/compare/v0.33.0...v0.34.0) (2026-08-05)

721
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -415,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+**.

View file

@ -107,7 +107,7 @@ There is **no native Windows wheel yet**, so pick one:
**A. Mechanism test (easiest — Docker Desktop or WSL2):**
```powershell
$env:HEADROOM_DOCKER_IMAGE = "ghcr.io/chopratejas/headroom:<branch-tag>" # ask the maintainer for the tag
$env:HEADROOM_DOCKER_IMAGE = "ghcr.io/headroomlabs-ai/headroom:<branch-tag>" # ask the maintainer for the tag
# run the Docker-native installer (scripts/install.ps1), then:
$env:GITHUB_COPILOT_TOKEN = "<your-token>"
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"

View file

@ -22,7 +22,7 @@ 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"
@ -125,7 +125,7 @@ blake3 = "1"
# image may lag behind. Sub-1 MB binary cost. WAL is enabled at
# connection-open time (see `ccr/backends/sqlite.rs`); no extra feature
# flags required.
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
# `redis` for the optional multi-worker CCR backend. Cfg-gated behind
# the `redis` feature so deploys that don't need it pay no compile
# cost. Default features include the sync `Connection` API used in
@ -196,7 +196,7 @@ redis = ["dep:redis"]
[dev-dependencies]
proptest = "1"
criterion = { version = "0.5", features = ["html_reports"] }
criterion = { version = "0.8", features = ["html_reports"] }
tempfile = "3"
[[bench]]

View file

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

View file

@ -0,0 +1,439 @@
//! 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");
format!("sha256:{:x}", Sha256::digest(canonical))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct PolicyVector {
channel: String,
requested: bool,
disabled: bool,
#[serde(rename = "unsafe")]
unsafe_override: bool,
enabled: bool,
decision: String,
}
#[test]
fn channel_order_matches_python_policy() {
assert!(RolloutChannel::Dev.allows(RolloutChannel::Canary));
assert!(RolloutChannel::Canary.allows(RolloutChannel::Beta));
assert!(!RolloutChannel::Stable.allows(RolloutChannel::Canary));
}
#[test]
fn stable_blocks_explicit_canary_feature_with_reason() {
let rollout = RolloutSnapshot::from_parts("stable", "canary_probe", "", false);
let decision = rollout.decision(Feature::CanaryProbe);
assert!(!decision.enabled);
assert_eq!(decision.reason, FeatureDecisionReason::BlockedByChannel);
}
#[test]
fn default_enabled_feature_has_default_reason() {
let rollout = RolloutSnapshot::default();
let decision = rollout.decision(Feature::NativeBedrock);
assert!(decision.enabled);
assert_eq!(decision.reason, FeatureDecisionReason::Default);
}
#[test]
fn unsafe_override_crosses_boundary_and_is_ineligible() {
let rollout = RolloutSnapshot::from_parts("stable", "canary_probe", "", true);
assert_eq!(
rollout.decision(Feature::CanaryProbe).reason,
FeatureDecisionReason::UnsafeOverride
);
assert!(!rollout.qualification_eligible());
assert_eq!(
rollout.to_value()["qualification_ineligible_reason"],
"unsafe_rollout_override_active"
);
}
#[test]
fn disable_beats_default_explicit_and_unsafe() {
for unsafe_override in [false, true] {
let rollout = RolloutSnapshot::from_parts(
"stable",
"native_bedrock",
"native-bedrock",
unsafe_override,
);
assert_eq!(
rollout.decision(Feature::NativeBedrock).reason,
FeatureDecisionReason::Disabled
);
}
}
#[test]
fn provenance_digests_are_deterministic_and_policy_sensitive() {
let first = RolloutSnapshot::from_parts("canary", "canary_probe", "", false);
let second = RolloutSnapshot::from_parts("canary", "canary_probe", "", false);
let changed = RolloutSnapshot::from_parts("stable", "canary_probe", "", false);
assert_eq!(first.registry_digest, second.registry_digest);
assert_eq!(first.snapshot_digest(), second.snapshot_digest());
assert_ne!(first.snapshot_digest(), changed.snapshot_digest());
}
#[test]
fn invalid_inputs_fail_closed() {
let rollout = RolloutSnapshot::from_parts("stabel", "unknown", "unknown", false);
assert_eq!(rollout.config.channel, RolloutChannel::Stable);
assert!(rollout.config.requested.is_empty());
assert!(rollout.config.disabled.is_empty());
}
#[test]
fn shared_python_rust_policy_vectors() {
let vectors: Vec<PolicyVector> = serde_json::from_str(include_str!(
"../../../tests/fixtures/rollout_policy_vectors.json"
))
.unwrap();
for vector in vectors {
let requested = if vector.requested { "canary_probe" } else { "" };
let disabled = if vector.disabled { "canary_probe" } else { "" };
let rollout = RolloutSnapshot::from_parts(
&vector.channel,
requested,
disabled,
vector.unsafe_override,
);
let decision = rollout.decision(Feature::CanaryProbe);
assert_eq!(decision.enabled, vector.enabled);
assert_eq!(
serde_json::to_value(decision.reason).unwrap(),
vector.decision
);
}
}
}

View file

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

View file

@ -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
@ -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"] }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,9 @@
//! Configuration for the proxy: CLI flags + env vars.
use clap::{Parser, ValueEnum};
use headroom_core::rollout::{
feature_names, split_feature_names, Feature, RolloutChannel, RolloutSnapshot,
};
use std::net::SocketAddr;
use std::time::Duration;
use url::Url;
@ -181,6 +184,48 @@ impl CompressionMode {
}
}
/// Session-sticky provider beta headers (parity port of the Python
/// proxy's `HEADROOM_BETA_HEADER_STICKY`; see
/// `cache_stabilization::beta_sticky`).
///
/// When `enabled` (default), the proxy unions each request's
/// `anthropic-beta` / `openai-beta` tokens with the tokens previously
/// seen for the same conversation and forwards the union, so a client
/// dropping a beta token mid-conversation doesn't rotate the upstream
/// prefix-cache key.
///
/// When `disabled`, the client header is forwarded verbatim and no
/// per-session token state is kept. Diagnostic operator opt-in — NOT
/// a fallback per realignment build constraint #4.
///
/// Source priority: CLI flag → `HEADROOM_PROXY_BETA_HEADER_STICKY`
/// env var → default (`enabled`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "snake_case")]
pub enum BetaHeaderSticky {
/// Union beta tokens per conversation and forward the union.
/// Default. Matches the Python proxy's default behaviour.
Enabled,
/// Forward the client's beta header verbatim; keep no state.
/// Diagnostic-only.
Disabled,
}
impl BetaHeaderSticky {
/// Stable snake_case name suitable for log fields.
pub fn as_str(self) -> &'static str {
match self {
BetaHeaderSticky::Enabled => "enabled",
BetaHeaderSticky::Disabled => "disabled",
}
}
/// Convenience: is the sticky union switched on?
pub fn is_enabled(self) -> bool {
matches!(self, BetaHeaderSticky::Enabled)
}
}
#[derive(Debug, Clone, Parser)]
#[command(
name = "headroom-proxy",
@ -188,6 +233,49 @@ impl CompressionMode {
about = "Headroom transparent reverse proxy"
)]
pub struct CliArgs {
/// Runtime rollout channel that bounds which managed features may run.
///
/// `stable` admits only features that have completed bake time. `beta` and
/// `canary` admit progressively newer features. `dev` is for local work.
/// Explicit feature requests still cannot cross this boundary unless the
/// unsafe override is set.
#[arg(
long = "rollout-channel",
env = "HEADROOM_ROLLOUT_CHANNEL",
default_value = "stable",
value_parser = parse_rollout_channel,
)]
pub rollout_channel: String,
/// Comma-separated rollout features to request explicitly.
#[arg(
long = "features",
env = "HEADROOM_FEATURES",
default_value = "",
value_parser = parse_rollout_features,
)]
pub features: String,
/// Comma-separated rollout features to force off. Disable wins over defaults
/// and explicit enable requests.
#[arg(
long = "disable-features",
env = "HEADROOM_DISABLE_FEATURES",
default_value = "",
value_parser = parse_rollout_features,
)]
pub disable_features: String,
/// Break-glass override that allows unstable features below their channel.
/// Intended only for emergency mitigation and should be visible in logs.
#[arg(
long = "unsafe-allow-unstable-features",
env = "HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES",
default_value_t = false,
action = clap::ArgAction::Set,
)]
pub unsafe_allow_unstable_features: bool,
/// Address the proxy listens on (e.g. 0.0.0.0:8787).
#[arg(long, env = "HEADROOM_PROXY_LISTEN", default_value = "0.0.0.0:8787")]
pub listen: SocketAddr,
@ -320,6 +408,28 @@ pub struct CliArgs {
)]
pub strip_internal_headers: StripInternalHeaders,
/// Session-sticky provider beta headers: union `anthropic-beta` /
/// `openai-beta` tokens per conversation so a client dropping a
/// token mid-conversation doesn't bust the upstream prefix cache.
/// Parity port of the Python proxy's `SessionBetaTracker` (PR-A6).
/// Default `enabled`; `disabled` is a diagnostic operator opt-in.
///
/// Active only when the compression interceptor is on
/// (`--compression` / `HEADROOM_PROXY_COMPRESSION=1`): with the
/// interceptor off the proxy is a strict byte-pipe and never
/// mutates headers. Startup logs a warning when this is `enabled`
/// while `--compression` is off.
///
/// Source priority: CLI flag → `HEADROOM_PROXY_BETA_HEADER_STICKY`
/// env var → default (`enabled`).
#[arg(
long = "beta-header-sticky",
env = "HEADROOM_PROXY_BETA_HEADER_STICKY",
value_enum,
default_value_t = BetaHeaderSticky::Enabled,
)]
pub beta_header_sticky: BetaHeaderSticky,
/// Phase C PR-C4: enable the `/v1/responses` SSE streaming
/// pipeline. When `true` (default), `Accept: text/event-stream`
/// requests on `/v1/responses` flow through the byte-level SSE
@ -475,6 +585,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())
@ -484,6 +620,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,
@ -517,6 +655,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,
@ -555,6 +696,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 {
@ -564,6 +729,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,
@ -578,9 +744,14 @@ 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),
bedrock_region: args.bedrock_region,
bedrock_endpoint: args.bedrock_endpoint,
aws_profile: args.aws_profile,
@ -594,6 +765,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),
@ -621,6 +793,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.
@ -644,3 +819,48 @@ impl Config {
}
}
}
#[cfg(test)]
mod rollout_input_tests {
use super::*;
#[test]
fn explicit_rollout_inputs_are_strict_and_diagnosable() {
assert_eq!(parse_rollout_channel("CANARY").unwrap(), "canary");
assert!(parse_rollout_channel("stabel")
.unwrap_err()
.contains("unknown rollout channel"));
assert!(parse_rollout_features("native-bedrock").is_ok());
let error = parse_rollout_features("native_bedrok").unwrap_err();
assert!(error.contains("native_bedrok"));
assert!(error.contains("native_bedrock"));
}
#[test]
fn legacy_false_flags_remain_effective_rollout_disables() {
let args = CliArgs::try_parse_from([
"headroom-proxy",
"--upstream",
"http://127.0.0.1:9",
"--enable-responses-streaming",
"false",
"--enable-bedrock-native",
"false",
])
.unwrap();
let config = Config::from_cli(args);
for feature in [Feature::OpenAiResponsesStreaming, Feature::NativeBedrock] {
let decision = config.rollout.decision(feature);
assert!(!decision.enabled);
assert!(decision.disabled);
assert_eq!(
decision.reason,
headroom_core::rollout::FeatureDecisionReason::Disabled
);
}
assert!(!config.enable_responses_streaming);
assert!(!config.enable_bedrock_native);
}
}

View file

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

View file

@ -28,9 +28,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
max_body_bytes = config.max_body_bytes,
rewrite_host = config.rewrite_host,
graceful_shutdown_timeout_s = config.graceful_shutdown_timeout.as_secs(),
rollout_channel = config.rollout.config.channel.as_str(),
rollout_features_enabled = ?config.rollout.enabled(),
rollout_features_disabled = ?config.rollout.config.disabled,
unsafe_allow_unstable_features = config.rollout.config.unsafe_allow_unstable,
rollout_registry_digest = %config.rollout.registry_digest,
rollout_snapshot_digest = %config.rollout.snapshot_digest(),
qualification_eligible = config.rollout.qualification_eligible(),
"headroom-proxy starting"
);
// Session-sticky beta headers only run inside the compression
// interceptor: with `--compression` off the proxy is a strict
// byte-pipe and never mutates headers. Say so loudly at startup —
// an operator reading `beta_header_sticky=enabled` (the default)
// must not believe the protection is active when it isn't.
if config.beta_header_sticky.is_enabled() && !config.compression {
tracing::warn!(
event = "beta_header_sticky_inactive",
beta_header_sticky = config.beta_header_sticky.as_str(),
compression = config.compression,
"beta-header stickiness is enabled but the compression \
interceptor is off; enable --compression (or \
HEADROOM_PROXY_COMPRESSION=1) to activate it"
);
}
let mut state = AppState::new(config.clone())?;
// PR-D1: resolve AWS credentials at startup via the `aws-config`

View file

@ -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
@ -707,6 +719,41 @@ pub(crate) async fn forward_http(
let session_key = derive_session_key(headers, &client_addr, &parsed, kind);
let hash = compute_structural_hash(&parsed, kind);
observe_drift(&state.drift_state, &session_key, hash);
// Session-sticky provider beta headers — port of the
// Python PR-A6 `SessionBetaTracker`. Beta headers are
// part of the bytes that determine the upstream
// prefix-cache key; a client dropping a token between
// turns rotates the key and re-writes the whole
// prefix at the customer's cost. Forward the
// per-conversation union instead. See
// `cache_stabilization::beta_sticky` for the behavior
// contract, the auth-mode rationale (applies to every
// mode, like the Python handler), and the one
// documented divergence from Python (per-conversation
// keying). Reuses the drift detector's `session_key`
// so both cache-stability subsystems agree on
// conversation identity. Mutates upstream-bound
// HEADERS only; body bytes stay untouched (Phase-A
// cache-safety invariant).
if state.config.beta_header_sticky.is_enabled() {
let provider = match endpoint {
compression::CompressibleEndpoint::AnthropicMessages => {
BetaProvider::Anthropic
}
compression::CompressibleEndpoint::OpenAiChatCompletions
| compression::CompressibleEndpoint::OpenAiResponses => {
BetaProvider::OpenAi
}
};
cache_stabilization::beta_sticky::apply_sticky_betas(
&state.beta_sticky,
provider,
&session_key,
&mut outgoing_headers,
&request_id,
);
}
}
}
let outcome = match endpoint {

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View file

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

View file

@ -284,6 +284,21 @@
"value": {
"intValue": "2"
}
},
{
"key": "failure_statuses",
"value": {
"kvlistValue": {
"values": [
{
"key": "529",
"value": {
"intValue": "2"
}
}
]
}
}
}
]
}

View file

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

View file

@ -28,9 +28,12 @@
* deanonymise install_id, so it is never read.
*/
// Mirrors the payload built by _Session.payload(). A key absent here is
// dropped, not stored. Adding a metric means adding it here first — that
// friction is the point.
// 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',
@ -42,6 +45,14 @@ const ALLOWED_KEYS = [
'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.
@ -110,7 +121,184 @@ function extract(payload) {
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 });
@ -134,14 +322,13 @@ export default {
}
if (records.length === 0) return new Response(null, { status: 204 });
const now = new Date();
const day = now.toISOString().slice(0, 10);
const hour = now.toISOString().slice(11, 13);
// Hive-style partitioning so DuckDB can prune by date without a catalog.
// ponytail: one object per request. At beacon volume that is a few hundred
// thousand objects a month, which globs fine. Add a daily compaction job
// when the file count starts to slow queries, not before.
const key = `sessions/dt=${day}/hh=${hour}/${crypto.randomUUID()}.json`;
// 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.

View file

@ -32,6 +32,24 @@ bucket_name = "headroom-telemetry"
# 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

View file

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

View file

@ -10,7 +10,7 @@
# 3. point your LLM client at http://localhost:8787 (proxy)
#
# Just want the proxy without the memory features? You can run the proxy image
# on its own (`docker run -p 8787:8787 ghcr.io/chopratejas/headroom`); the two
# on its own (`docker run -p 8787:8787 ghcr.io/headroomlabs-ai/headroom`); the two
# database services below are only required for the memory/relevance features.
#
# Ports exposed on the host:

View file

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

View file

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

View file

@ -5,6 +5,20 @@ 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.
## Runtime Rollout Channels
Headroom uses rollout channels to control which behaviors an already-installed
artifact may expose. They do not select a package or released version.
| 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.
## SDK Modes (`default_mode` / `headroom_mode`)
@ -294,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` |

View file

@ -40,6 +40,7 @@
"vscode-claude-code",
"vscode-copilot",
"opencode",
"opencode-deepseek",
"grok-build",
"mcp",
"---Configuration---",
@ -57,6 +58,7 @@
"architecture",
"ci-cd-flows",
"releases",
"runtime-rollouts",
"benchmarks",
"limitations",
"---Help---",

View file

@ -113,7 +113,11 @@ HEADROOM_OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod
| `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`, `headroom.proxy.tokens.output`, `headroom.proxy.tokens.saved`, and `headroom.proxy.cache.read_tokens` / `write_tokens`.
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`.

View file

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

View file

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

View file

@ -68,7 +68,7 @@ Avoid setting process-wide variables such as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_P
|--------|---------|-------------|
| `--mode token` | | Prioritize token compression; prior turns may be rewritten for maximum savings. |
| `--mode cache` | default | Freeze prior turns to maximize provider prefix-cache hit rate. This is the effective default (see [Savings profiles](#savings-profiles)). |
| `--intercept-tool-results` | `false` | Opt into tool-result interceptors such as ast-grep Read outlining. |
| `--intercept-tool-results` | `false` | Opt into canary tool-result interceptors such as ast-grep Read outlining. Requires `HEADROOM_ROLLOUT_CHANNEL=canary` (or `dev`). |
| `--no-read-lifecycle` | `false` | Disable stale/superseded Read-output compression. |
| `--code-aware` / `--no-code-aware` | disabled | Enable or disable AST-based code compression. Requires `headroom-ai[code]`. |
| `--code-graph` | `false` | Enable the proxy's live code-graph file watcher for the current project. |
@ -249,7 +249,7 @@ Coding agents re-read the same files repeatedly; these control how stale reads a
| Flag / env | Default | Effect |
|---|---|---|
| `--no-read-lifecycle` | lifecycle on | Stop replacing stale/superseded file reads with CCR markers. |
| `--read-maturation` / `HEADROOM_READ_MATURATION` | `false` | *(Experimental)* Hold freshly-read files out of the prefix cache until the file quiesces. |
| `--read-maturation` / `HEADROOM_READ_MATURATION` | `false` | *(Beta)* Hold freshly-read files out of the prefix cache until the file quiesces. Requires `HEADROOM_ROLLOUT_CHANNEL=beta` (or `dev`). |
| `--read-maturation-quiesce-turns` | `5` | Turns of no change before a held read is admitted. |
### Reliability: timeouts, retries, limits

View file

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

View file

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

View file

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

View file

@ -245,6 +245,21 @@ Every label vocabulary is bounded by code, not customer input:
`"other"` and a `tracing::warn!` is emitted so wire-format drift
surfaces loudly in logs.
- `status`: 5-variant enum.
- `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.

16
docs/package-lock.json generated
View file

@ -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"
}
@ -5629,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",
@ -5816,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",
@ -5835,7 +5835,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -36,7 +36,7 @@
"@types/react-dom": "^19.2.3",
"ai": "^6.0.149",
"openai": "^6.47.0",
"postcss": "^8.5.19",
"postcss": "^8.5.26",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
},

View file

@ -790,6 +790,11 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None:
f'"http://127.0.0.1:{port}{project_prefix}"' in configured,
"VS Code wrap should configure the project-scoped proxy URL",
)
assert_true(
f'"github.copilot.advanced.debug.overrideCapiUrl": '
f'"http://127.0.0.1:{port}{project_prefix}"' in configured,
"VS Code wrap should route Copilot Chat generation through Headroom",
)
assert_true(
'"github.copilot.advanced.debug.overrideAuthType": "token"' in configured,
"VS Code wrap should configure token auth",
@ -849,8 +854,8 @@ def verify_vscode_claude_wrap(base_env: dict[str, str], project_dir: Path) -> No
"VS Code Claude wrap should configure the project-scoped Anthropic URL",
)
assert_true(
configured["env"]["ENABLE_TOOL_SEARCH"] == "true",
"VS Code Claude wrap should retain Claude Code tool deferral",
configured["env"]["ENABLE_TOOL_SEARCH"] == "false",
"VS Code Claude wrap should disable tool deferral for webview compatibility",
)
assert_true(configured["env"]["KEEP"] == "yes", "Existing Claude env must remain")
assert_true(str(settings_path) in output, "Wrap output should identify Claude settings")

View file

@ -40,15 +40,40 @@ import importlib.util
import logging
import os
import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
logger = logging.getLogger(__name__)
_ENV_VAR = "ORT_DYLIB_PATH"
_MIN_RUST_ORT_API_VERSION = (1, 24)
# Tri-state module cache: unset sentinel / resolved path / None (no pin).
_UNSET = object()
_pinned: object = _UNSET
_pinned_from_override = False
def _installed_ort_version() -> tuple[int, int] | None:
"""Return the installed ONNX Runtime major/minor without importing it."""
try:
raw = version("onnxruntime")
return tuple(int(part) for part in raw.split(".")[:2]) # type: ignore[return-value]
except (PackageNotFoundError, ValueError):
return None
def rust_ort_runtime_compatible() -> bool:
"""Whether native Rust detection can safely initialize ORT C API 24.
A caller-supplied ``ORT_DYLIB_PATH`` remains an explicit override: its
library may be newer than the separately installed Python package. The
auto-pinned package library, however, must advertise at least 1.24.
"""
if _pinned_from_override:
return True
installed = _installed_ort_version()
return installed is not None and installed >= _MIN_RUST_ORT_API_VERSION
def ensure_ort_dylib_pinned() -> str | None:
@ -84,12 +109,24 @@ def _resolve_ort_native_library(capi_dir: Path) -> Path | None:
def _resolve_and_pin() -> str | None:
global _pinned_from_override
try:
existing = os.environ.get(_ENV_VAR)
if existing:
_pinned_from_override = True
logger.debug("%s already set; respecting user override: %s", _ENV_VAR, existing)
return existing
installed = _installed_ort_version()
if installed is not None and installed < _MIN_RUST_ORT_API_VERSION:
logger.warning(
"onnxruntime %d.%d exposes an older C API than Rust detection "
"requires (1.24+); leaving %s unset and using Python detection",
*installed,
_ENV_VAR,
)
return None
spec = importlib.util.find_spec("onnxruntime")
if spec is None or not spec.origin:
logger.debug(

View file

@ -25,6 +25,44 @@ except ImportError:
AnyLLM = None # type: ignore
def _convert_anthropic_tool(tool: dict[str, Any]) -> dict[str, Any]:
"""Convert an Anthropic tool definition to the OpenAI function shape.
any-llm speaks OpenAI, so an Anthropic ``{name, description, input_schema}``
tool must become ``{type: function, function: {name, description,
parameters}}`` before it is forwarded, or the provider ignores/rejects the
tools array and the model never calls a tool. Mirrors the LiteLLM backend's
converter so both OpenAI-compatible backends send the same shape.
"""
func: dict[str, Any] = {"name": tool.get("name", "")}
if "description" in tool:
func["description"] = tool["description"]
if "input_schema" in tool:
func["parameters"] = tool["input_schema"]
return {"type": "function", "function": func}
def _convert_tool_choice(choice: Any) -> Any:
"""Convert an Anthropic ``tool_choice`` to the OpenAI shape (mirrors LiteLLM).
Anthropic: ``{"type": "auto"}``, ``{"type": "any"}``, ``{"type": "tool",
"name": ...}``. OpenAI: ``"auto"``, ``"required"``, ``{"type": "function",
"function": {"name": ...}}``. Passing the raw Anthropic dict through makes
the provider reject or ignore it.
"""
if isinstance(choice, str):
return choice
if isinstance(choice, dict):
choice_type = choice.get("type", "auto")
if choice_type == "auto":
return "auto"
if choice_type == "any":
return "required"
if choice_type == "tool":
return {"type": "function", "function": {"name": choice.get("name", "")}}
return "auto"
class AnyLLMBackend(Backend):
"""Backend using any-llm for multi-provider support."""
@ -251,9 +289,9 @@ class AnyLLMBackend(Backend):
if "stop_sequences" in body:
kwargs["stop"] = body["stop_sequences"]
if "tools" in body:
kwargs["tools"] = body["tools"]
kwargs["tools"] = [_convert_anthropic_tool(t) for t in body["tools"]]
if "tool_choice" in body:
kwargs["tool_choice"] = body["tool_choice"]
kwargs["tool_choice"] = _convert_tool_choice(body["tool_choice"])
logger.debug(f"any-llm request: provider={self.provider}, model={original_model}")
@ -301,9 +339,9 @@ class AnyLLMBackend(Backend):
if "stop_sequences" in body:
kwargs["stop"] = body["stop_sequences"]
if "tools" in body:
kwargs["tools"] = body["tools"]
kwargs["tools"] = [_convert_anthropic_tool(t) for t in body["tools"]]
if "tool_choice" in body:
kwargs["tool_choice"] = body["tool_choice"]
kwargs["tool_choice"] = _convert_tool_choice(body["tool_choice"])
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
@ -324,42 +362,131 @@ class AnyLLMBackend(Backend):
},
)
yield StreamEvent(
event_type="content_block_start",
data={
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
},
)
stream_response = await self.llm.acompletion(**kwargs)
output_tokens = 0
# Stream text immediately in a single text block, but BUFFER tool
# calls and emit them as complete blocks at the end. OpenAI streams
# parallel tool calls interleaved by index (index 0 and 1 introduced
# together, then a fragment for 0, then for 1), while Anthropic
# requires each content block to be fully emitted — start, deltas,
# stop — before the next opens. Reassembling per index and flushing
# complete blocks keeps every delta inside its own block's start/stop
# for any interleaving. (The previous version pre-opened one text
# block and dropped tool calls entirely; a naive open-on-new-index
# instead mis-sequenced parallel calls, emitting a fragment for an
# already-stopped block.)
current_block_index = -1
text_block_open = False
# provider tool index -> {"id", "name", "arguments"}, first-seen order
tool_calls: dict[int, dict[str, Any]] = {}
tool_order: list[int] = []
stop_reason = "end_turn"
async for chunk in cast(AsyncIterator[Any], stream_response):
if hasattr(chunk, "choices") and chunk.choices:
delta = chunk.choices[0].delta
if hasattr(delta, "content") and delta.content:
if not (hasattr(chunk, "choices") and chunk.choices):
continue
choice = chunk.choices[0]
delta = choice.delta
# Map OpenAI finish_reason to the Anthropic stop_reason so a tool
# call or a length truncation is not reported as end_turn.
finish_reason = getattr(choice, "finish_reason", None)
if finish_reason == "tool_calls":
stop_reason = "tool_use"
elif finish_reason == "length":
stop_reason = "max_tokens"
elif finish_reason == "stop":
stop_reason = "end_turn"
if getattr(delta, "tool_calls", None):
for tc in delta.tool_calls:
idx = tc.index if getattr(tc, "index", None) is not None else 0
buf = tool_calls.get(idx)
if buf is None:
buf = {"id": None, "name": "", "arguments": ""}
tool_calls[idx] = buf
tool_order.append(idx)
if getattr(tc, "id", None):
buf["id"] = tc.id
func = getattr(tc, "function", None)
if func is not None:
if getattr(func, "name", None):
buf["name"] = func.name
if getattr(func, "arguments", None):
buf["arguments"] += func.arguments
elif getattr(delta, "content", None):
if not text_block_open:
current_block_index += 1
text_block_open = True
yield StreamEvent(
event_type="content_block_delta",
event_type="content_block_start",
data={
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": delta.content},
"type": "content_block_start",
"index": current_block_index,
"content_block": {"type": "text", "text": ""},
},
)
output_tokens += 1
yield StreamEvent(
event_type="content_block_delta",
data={
"type": "content_block_delta",
"index": current_block_index,
"delta": {"type": "text_delta", "text": delta.content},
},
)
output_tokens += 1
yield StreamEvent(
event_type="content_block_stop",
data={"type": "content_block_stop", "index": 0},
)
# Close the text block before any tool blocks (Anthropic orders
# content blocks sequentially, text then tool_use).
if text_block_open:
yield StreamEvent(
event_type="content_block_stop",
data={"type": "content_block_stop", "index": current_block_index},
)
# Flush each buffered tool call as a complete, self-contained block:
# start, one input_json_delta with the reassembled arguments, stop.
for idx in tool_order:
buf = tool_calls[idx]
current_block_index += 1
tool_id = buf["id"] or f"toolu_{uuid.uuid4().hex[:24]}"
yield StreamEvent(
event_type="content_block_start",
data={
"type": "content_block_start",
"index": current_block_index,
"content_block": {
"type": "tool_use",
"id": tool_id,
"name": buf["name"],
"input": {},
},
},
)
if buf["arguments"]:
yield StreamEvent(
event_type="content_block_delta",
data={
"type": "content_block_delta",
"index": current_block_index,
"delta": {
"type": "input_json_delta",
"partial_json": buf["arguments"],
},
},
)
output_tokens += 1
yield StreamEvent(
event_type="content_block_stop",
data={"type": "content_block_stop", "index": current_block_index},
)
yield StreamEvent(
event_type="message_delta",
data={
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"output_tokens": output_tokens},
},
)

View file

@ -413,6 +413,81 @@ PROVIDER_REGISTRY: dict[str, ProviderConfig] = {
}
# How long an upstream call may go silent before we give up on it.
#
# WHY THIS EXISTS. There was no timeout here at all, so a request the upstream
# never answered blocked its caller forever. Observed 2026-08-07 under load:
# four agent workers sat on ESTABLISHED connections for 36+ minutes while this
# proxy answered /readyz in 0.11s. No error, no retry, no log line -- the
# client just stops. That is the worst shape a failure can take, because it is
# indistinguishable from slow work and no supervisor can tell the difference.
#
# A float, not an httpx.Timeout, on purpose: litellm expands a float into all
# four httpx phases, so for a STREAMING call this becomes the maximum gap
# BETWEEN CHUNKS rather than a cap on total generation time. A long answer
# streaming steadily is never cut off; a stalled one dies. That is the
# semantic we want, and it falls out of the simpler type.
#
# 600s is deliberately generous -- long enough that no healthy call is at
# risk, short enough that a hang surfaces within a coffee break instead of
# never.
UPSTREAM_TIMEOUT_ENV = "HEADROOM_UPSTREAM_TIMEOUT"
DEFAULT_UPSTREAM_TIMEOUT = 600.0
def _upstream_timeout() -> float:
"""Seconds. Never raises; a junk env value must not disable the timeout."""
import os
try:
v = float(os.getenv(UPSTREAM_TIMEOUT_ENV, DEFAULT_UPSTREAM_TIMEOUT))
except (TypeError, ValueError):
return DEFAULT_UPSTREAM_TIMEOUT
# 0 or negative would mean "no timeout" to httpx, which is the bug.
return v if v > 0 else DEFAULT_UPSTREAM_TIMEOUT
# Providers that cannot possibly accept an Anthropic `sk-ant-` credential.
#
# Explicit, rather than the inverse "anything not Anthropic": an unrecognised
# provider is usually a compatible or self-hosted gateway, and guessing wrong
# there drops a key that WAS working. Bedrock/Vertex are absent because the
# dispatch sites already skip them entirely (env-based auth).
#
# ponytail: a hand-kept tuple; grow it as targets are confirmed. A registry
# lookup would be the upgrade if this ever outgrows a handful of entries.
_REJECTS_ANTHROPIC_KEY = ("openai", "azure", "gemini")
def _caller_key_travels_to(model: str, key: str) -> bool:
"""Can this inbound credential authenticate the provider we are about to call?
The caller authenticates to the PROXY. A routing extension may then rewrite
the model across families mid-request (claude-opus-5 -> gpt-5-mini), and the
caller's key does not travel with that rewrite: we forward `sk-ant-...` to
OpenAI and earn a guaranteed 401, which reads downstream as "the cheap model
failed the task" rather than as the routing bug it is.
Only an unambiguous mismatch is refused. `sk-ant-` is Anthropic's documented
vendor-specific prefix, so it cannot authenticate one of the providers above.
Every other credential -- a plain Bearer token, an OpenAI-style `sk-` that a
dozen vendors also mint, anything aimed at a compatible or custom gateway --
is unclassifiable from the string alone and keeps the pass-through.
Returning False drops the api_key kwarg, so litellm falls back to the target
provider's own env credential: the only key that can work.
"""
if not key.startswith("sk-ant-"):
return True
try:
from litellm import get_llm_provider
provider = (get_llm_provider(model)[1] or "").lower()
except Exception: # noqa: BLE001 - unclassifiable model, keep pass-through
return True
return provider not in _REJECTS_ANTHROPIC_KEY
def get_provider_config(provider: str) -> ProviderConfig:
"""Get provider config, with fallback for unknown providers."""
if provider in PROVIDER_REGISTRY:
@ -622,6 +697,21 @@ class LiteLLMBackend(Backend):
if anthropic_model.startswith("arn:aws:"):
return f"bedrock/converse/{anthropic_model}"
# Cross-region prefixed IDs are already fully qualified system-defined
# profile IDs — pass through directly. Normalizing and re-looking them
# up in the discovery map can route the request to a wrong or
# unauthorized profile (e.g. an APPLICATION profile in the same account
# that also wraps the same foundation model). This applies whether the
# prefix arrives bare ("us.anthropic...") or already LiteLLM-qualified
# ("bedrock/us.anthropic...").
_CROSS_REGION_PREFIXES = ("au.", "us.", "eu.", "apac.", "global.")
if anthropic_model.startswith(_CROSS_REGION_PREFIXES):
return f"bedrock/{anthropic_model}"
if anthropic_model.startswith("bedrock/") and anthropic_model[
len("bedrock/") :
].startswith(_CROSS_REGION_PREFIXES):
return anthropic_model
normalized = _normalize_bedrock_profile_id(anthropic_model)
if normalized and normalized in self._model_map:
return self._model_map[normalized]
@ -917,14 +1007,21 @@ class LiteLLMBackend(Backend):
_env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker")
if self.provider not in _env_auth_providers:
auth_header = headers.get("authorization", headers.get("Authorization", ""))
if auth_header.startswith("Bearer "):
kwargs["api_key"] = auth_header[7:]
elif headers.get("x-api-key"):
kwargs["api_key"] = headers["x-api-key"]
_caller_key = (
auth_header[7:]
if auth_header.startswith("Bearer ")
else headers.get("x-api-key", "")
)
# Only forward it if it can actually authenticate the TARGET.
if _caller_key and _caller_key_travels_to(litellm_model, _caller_key):
kwargs["api_key"] = _caller_key
logger.debug(f"LiteLLM request: model={litellm_model}")
# Make the call
# Bounded, always: an upstream that never answers must not
# block the caller forever. setdefault so an explicit value wins.
kwargs.setdefault("timeout", _upstream_timeout())
response = await acompletion(**kwargs)
# Convert to Anthropic format
@ -1022,10 +1119,14 @@ class LiteLLMBackend(Backend):
_env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker")
if self.provider not in _env_auth_providers:
auth_header = headers.get("authorization", headers.get("Authorization", ""))
if auth_header.startswith("Bearer "):
kwargs["api_key"] = auth_header[7:]
elif headers.get("x-api-key"):
kwargs["api_key"] = headers["x-api-key"]
_caller_key = (
auth_header[7:]
if auth_header.startswith("Bearer ")
else headers.get("x-api-key", "")
)
# Only forward it if it can actually authenticate the TARGET.
if _caller_key and _caller_key_travels_to(litellm_model, _caller_key):
kwargs["api_key"] = _caller_key
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
@ -1055,6 +1156,9 @@ class LiteLLMBackend(Backend):
kwargs["stream_options"] = {"include_usage": True}
# Stream content — blocks emitted dynamically based on response
# Bounded, always: an upstream that never answers must not
# block the caller forever. setdefault so an explicit value wins.
kwargs.setdefault("timeout", _upstream_timeout())
response = await acompletion(**kwargs)
output_tokens = 0
current_block_index = -1
@ -1275,14 +1379,21 @@ class LiteLLMBackend(Backend):
_env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker")
if self.provider not in _env_auth_providers:
auth_header = headers.get("authorization", headers.get("Authorization", ""))
if auth_header.startswith("Bearer "):
kwargs["api_key"] = auth_header[7:]
elif headers.get("x-api-key"):
kwargs["api_key"] = headers["x-api-key"]
_caller_key = (
auth_header[7:]
if auth_header.startswith("Bearer ")
else headers.get("x-api-key", "")
)
# Only forward it if it can actually authenticate the TARGET.
if _caller_key and _caller_key_travels_to(litellm_model, _caller_key):
kwargs["api_key"] = _caller_key
logger.debug(f"LiteLLM OpenAI request: model={litellm_model}")
# Make the call
# Bounded, always: an upstream that never answers must not
# block the caller forever. setdefault so an explicit value wins.
kwargs.setdefault("timeout", _upstream_timeout())
response = await acompletion(**kwargs)
# Build the usage block. LiteLLM normalizes prompt-cache stats from
@ -1293,10 +1404,16 @@ class LiteLLMBackend(Backend):
# cache_creation_tokens for the OpenAI nested dialect. Surface both
# so PrefixCacheTracker.update_from_response on the backend-routed
# path observes a stable shape instead of branching on key presence.
# None-guard the core counts (same defensive style as the cache
# fields just below). A provider can leave any of these None on the
# Usage object; emitting None here flows into the OpenAI-shape body,
# and the backend-routed OpenAI handler reads them straight into
# arithmetic and RequestOutcome (output_tokens=..., and
# max(0, prompt_tokens - ...)), which raises TypeError on None.
usage_block: dict[str, Any] = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
"prompt_tokens": int(getattr(response.usage, "prompt_tokens", 0) or 0),
"completion_tokens": int(getattr(response.usage, "completion_tokens", 0) or 0),
"total_tokens": int(getattr(response.usage, "total_tokens", 0) or 0),
}
# Defensive getattr: LiteLLM only attaches these top-level attrs
@ -1447,11 +1564,18 @@ class LiteLLMBackend(Backend):
_env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker")
if self.provider not in _env_auth_providers:
auth_header = headers.get("authorization", headers.get("Authorization", ""))
if auth_header.startswith("Bearer "):
kwargs["api_key"] = auth_header[7:]
elif headers.get("x-api-key"):
kwargs["api_key"] = headers["x-api-key"]
_caller_key = (
auth_header[7:]
if auth_header.startswith("Bearer ")
else headers.get("x-api-key", "")
)
# Only forward it if it can actually authenticate the TARGET.
if _caller_key and _caller_key_travels_to(litellm_model, _caller_key):
kwargs["api_key"] = _caller_key
# Bounded, always: an upstream that never answers must not
# block the caller forever. setdefault so an explicit value wins.
kwargs.setdefault("timeout", _upstream_timeout())
response = await acompletion(**kwargs)
async for chunk in response:

View file

@ -29,6 +29,7 @@ import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
@ -245,12 +246,24 @@ def _download(url: str, dest: Path, *, progress: bool = True) -> None:
raise OSError(f"binary cache directory is not writable: {dest.parent}")
final_url = _mirror_url(url)
req = urllib.request.Request(final_url, headers={"User-Agent": "headroom-binaries/1"})
try:
with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 (https)
total = int(resp.headers.get("Content-Length") or 0)
_stream_to(resp, dest, total, label=dest.name, show_progress=progress)
except urllib.error.URLError as e:
raise BinaryFetchError(f"failed to download {final_url}: {e}") from e
attempts = 3
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 (https)
total = int(resp.headers.get("Content-Length") or 0)
_stream_to(resp, dest, total, label=dest.name, show_progress=progress)
return
except urllib.error.URLError as e:
dest.unlink(missing_ok=True)
if attempt == attempts:
raise BinaryFetchError(
f"failed to download {final_url} after {attempts} attempts: {e}"
) from e
# GitHub release assets occasionally return a transient 5xx or
# reset while redirecting to the object store. A short bounded
# retry keeps proxy startup reliable without hiding persistent
# credential, mirror, or connectivity failures.
time.sleep(0.25 * attempt)
def _stream_to(src: Any, dest: Path, total: int, *, label: str, show_progress: bool) -> None:

View file

@ -135,8 +135,10 @@ class CompressionCache:
# `compute_frozen_count` (bounded above by the `min` clamp at
# `proxy/handlers/anthropic.py`) and `update_from_result`'s
# "unchanged content" tracking.
self._stable_hashes: set[str] = set()
self._first_seen: dict[str, float] = {}
# Ordered mappings preserve set/dict-style membership while allowing
# deterministic oldest-first eviction.
self._stable_hashes: OrderedDict[str, None] = OrderedDict()
self._first_seen: OrderedDict[str, float] = OrderedDict()
self._hits: int = 0
self._misses: int = 0
self._total_tokens_saved: int = 0
@ -172,6 +174,34 @@ class CompressionCache:
_, evicted = self._cache.popitem(last=False)
self._total_tokens_saved -= evicted.tokens_saved
def _mark_stable_locked(self, content_hash: str) -> None:
"""Record a stable hash while bounding retained bookkeeping."""
self._stable_hashes[content_hash] = None
self._stable_hashes.move_to_end(content_hash)
while len(self._stable_hashes) > self.max_entries:
self._stable_hashes.popitem(last=False)
def _record_first_seen_locked(self, content_hash: str, seen_at: float) -> None:
"""Record a first-seen timestamp while bounding retained bookkeeping."""
self._first_seen[content_hash] = seen_at
self._first_seen.move_to_end(content_hash)
while len(self._first_seen) > self.max_entries:
self._first_seen.popitem(last=False)
def _prune_expired_first_seen_locked(
self,
now: float,
ttl_seconds: float,
) -> None:
"""Remove first-seen entries whose cache timing window has expired."""
while self._first_seen:
_, oldest_seen_at = next(iter(self._first_seen.items()))
if now - oldest_seen_at < ttl_seconds:
break
self._first_seen.popitem(last=False)
def mark_stable(self, content_hash: str) -> None:
"""Mark a content hash as stable (unchanged, not compressed).
@ -180,7 +210,7 @@ class CompressionCache:
even though no compressed version exists in the cache.
"""
with self._lock:
self._stable_hashes.add(content_hash)
self._mark_stable_locked(content_hash)
def mark_stable_from_messages(self, messages: list[dict], up_to: int) -> None:
"""Mark all tool_result hashes in messages[:up_to] as stable."""
@ -189,7 +219,7 @@ class CompressionCache:
if _is_tool_result_message(msg):
content = _extract_tool_result_content(msg)
if content is not None:
self._stable_hashes.add(self.content_hash(content))
self._mark_stable_locked(self.content_hash(content))
def should_defer_compression(
self,
@ -216,13 +246,18 @@ class CompressionCache:
"""
with self._lock:
now = time.time()
self._prune_expired_first_seen_locked(now, ttl_seconds)
first_seen = self._first_seen.get(content_hash)
if first_seen is None:
self._first_seen[content_hash] = now
self._record_first_seen_locked(content_hash, now)
return False # First time — compress now (no cache entry to preserve)
age = now - first_seen
if age >= ttl_seconds - batch_window:
self._record_first_seen_locked(content_hash, now)
return False # Near TTL boundary — compress now (batch window)
return True # Seen recently within TTL — defer to preserve cache
def get_stats(self) -> dict:
@ -335,7 +370,7 @@ class CompressionCache:
continue
if orig_content == comp_content:
# Content unchanged — mark as stable for frozen count walk
self._stable_hashes.add(self.content_hash(orig_content))
self._mark_stable_locked(self.content_hash(orig_content))
continue
h = self.content_hash(orig_content)
tokens_saved = len(orig_content) // 4 - len(comp_content) // 4

View file

@ -52,6 +52,10 @@ DEFAULT_CCR_TTL_SECONDS = 1800 # session-scale; override via HEADROOM_CCR_TTL_S
CCR_TTL_SECONDS_ENV = "HEADROOM_CCR_TTL_SECONDS"
_RETRIEVAL_LOG_PREVIEW_CHARS = 4096
# Previews carry verbatim tool-result content (post-redaction), which makes
# proxy.log too sensitive for users to share in bug reports. Set to
# 0/false/no/off to log byte counts only.
PAYLOAD_PREVIEW_ENV = "HEADROOM_LOG_PAYLOAD_PREVIEW"
_SECRET_KEY_VALUE_RE = re.compile(
r"(?i)\b([A-Z0-9_-]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_-]*)"
r"(\s*[:=]\s*)([\"']?)([^\"'\s,}]+)"
@ -108,7 +112,21 @@ def _redact_retrieval_log_payload(payload: str) -> str:
return _API_KEY_VALUE_RE.sub("sk-[REDACTED]", redacted)
def _payload_preview_enabled() -> bool:
raw = os.environ.get(PAYLOAD_PREVIEW_ENV)
if raw is None:
return True
return raw.strip().lower() not in ("0", "false", "no", "off")
def _payload_for_retrieval_log(payload: str) -> dict[str, Any]:
if not _payload_preview_enabled():
return {
"payload_chars": len(payload),
"payload_preview_chars": 0,
"payload_truncated": len(payload) > 0,
"payload_preview": "",
}
redacted = _redact_retrieval_log_payload(payload)
preview = redacted[:_RETRIEVAL_LOG_PREVIEW_CHARS]
truncated = len(redacted) > len(preview)

View file

@ -21,6 +21,7 @@ import hashlib
import itertools
import json
import logging
import os
import time
from collections import OrderedDict
from dataclasses import dataclass
@ -229,6 +230,184 @@ def _canonicalize_for_prefix_compare(obj: Any) -> Any:
return obj
# Canonical relationships between consecutive histories. These constants are
# strings (rather than an Enum) so they remain cheap to log on the request hot
# path and easy to assert in tests.
RELATION_EXACT = "exact"
RELATION_MESSAGE_APPEND = "message_append"
RELATION_BLOCK_APPEND = "block_append"
RELATION_BLOCK_REWRITE_TAIL = "block_rewrite_tail"
RELATION_DIVERGED = "diverged"
@dataclass(frozen=True)
class HistoryRelation:
"""How a current message history relates to one recorded last turn.
``block_append`` and ``block_rewrite_tail`` are deliberately distinct:
Anthropic should keep the breakpoint on the newest block for a pure append,
but anchor it to ``stable_prefix_blocks - 1`` when the previous tail was
rewritten and therefore can never match a prior cache write (#2671).
"""
kind: str
message_index: int | None = None
stable_prefix_blocks: int = 0
stable_suffix_blocks: int = 0
previous_block_count: int = 0
current_block_count: int = 0
# A rewritten-tail match is intentionally conservative. The production shape
# behind #2671 has a hundred-plus-block stable prefix and a fixed two-block
# suffix. Requiring both avoids merging sibling sub-calls which merely share a
# short injected preamble or a single generic reminder at the end.
_MIN_REWRITE_PREFIX_BLOCKS = 8
_MIN_REWRITE_SUFFIX_BLOCKS = 2
def _message_fields_outside_content(message: dict[str, Any]) -> dict[str, Any]:
"""Return message identity fields, excluding the block list itself."""
return {key: value for key, value in message.items() if key != "content"}
def _stable_leading_block_run(current: list[Any], previous: list[Any]) -> int:
"""Number of canonical-equal blocks at the start of both lists."""
limit = min(len(current), len(previous))
run = 0
while run < limit and current[run] == previous[run]:
run += 1
return run
def _stable_trailing_block_run(current: list[Any], previous: list[Any], *, leading_run: int) -> int:
"""Non-overlapping canonical-equal suffix length."""
limit = min(len(current), len(previous)) - leading_run
run = 0
while run < limit and current[-(run + 1)] == previous[-(run + 1)]:
run += 1
return run
def _classify_history_canonical(
current_messages: list[Any], previous_messages: list[Any]
) -> HistoryRelation:
"""Classify two already-canonical, structurally snapshotted histories."""
if not previous_messages or len(current_messages) < len(previous_messages):
return HistoryRelation(RELATION_DIVERGED)
changed: HistoryRelation | None = None
for index, previous_message in enumerate(previous_messages):
current_message = current_messages[index]
if current_message == previous_message:
continue
if changed is not None:
return HistoryRelation(RELATION_DIVERGED)
if not isinstance(previous_message, dict) or not isinstance(current_message, dict):
return HistoryRelation(RELATION_DIVERGED)
if _message_fields_outside_content(previous_message) != _message_fields_outside_content(
current_message
):
return HistoryRelation(RELATION_DIVERGED)
previous_blocks = previous_message.get("content")
current_blocks = current_message.get("content")
if not isinstance(previous_blocks, list) or not isinstance(current_blocks, list):
return HistoryRelation(RELATION_DIVERGED)
previous_count = len(previous_blocks)
current_count = len(current_blocks)
leading = _stable_leading_block_run(current_blocks, previous_blocks)
# Pure block append. The previous write remains intact and Anthropic's
# lookback can find it, so the breakpoint must advance to the newest
# block and cover the newly appended tail.
if current_count > previous_count and leading == previous_count:
changed = HistoryRelation(
RELATION_BLOCK_APPEND,
message_index=index,
stable_prefix_blocks=leading,
previous_block_count=previous_count,
current_block_count=current_count,
)
continue
# Rewritten-tail growth. This is narrower than a fuzzy prefix match:
# message count may not change, content may not shrink, most of the old
# prefix must survive, and a substantial fixed suffix must identify the
# sub-call. Crucially ``leading < previous_count`` proves this is NOT a
# pure append (the bug in the original #2702 discriminator).
trailing = _stable_trailing_block_run(current_blocks, previous_blocks, leading_run=leading)
if (
len(current_messages) == len(previous_messages)
and current_count >= previous_count
and _MIN_REWRITE_PREFIX_BLOCKS <= leading < previous_count
and leading * 2 >= previous_count
and leading * 2 >= current_count
and trailing >= _MIN_REWRITE_SUFFIX_BLOCKS
):
changed = HistoryRelation(
RELATION_BLOCK_REWRITE_TAIL,
message_index=index,
stable_prefix_blocks=leading,
stable_suffix_blocks=trailing,
previous_block_count=previous_count,
current_block_count=current_count,
)
continue
return HistoryRelation(RELATION_DIVERGED)
if changed is not None:
return changed
return HistoryRelation(
RELATION_EXACT
if len(current_messages) == len(previous_messages)
else RELATION_MESSAGE_APPEND
)
def classify_history_relation(
current_messages: list[dict[str, Any]],
previous_messages: list[dict[str, Any]],
) -> HistoryRelation:
"""Return the canonical cross-turn relationship for two raw histories.
The canonical projection may drop a whole directive-only message. Refuse
classification when that would shift raw message indices: block replay
always slices the raw lists and must never consume a canonical index as a
raw one.
"""
if not current_messages or not previous_messages:
return HistoryRelation(RELATION_DIVERGED)
current = _lineage_snapshot(_canonicalize_for_prefix_compare(current_messages))
previous = _lineage_snapshot(_canonicalize_for_prefix_compare(previous_messages))
prefix_len = len(previous_messages)
if len(previous) != prefix_len:
return HistoryRelation(RELATION_DIVERGED)
if len(_canonicalize_for_prefix_compare(current_messages[:prefix_len])) != prefix_len:
return HistoryRelation(RELATION_DIVERGED)
return _classify_history_canonical(current, previous)
def segment_fingerprint(value: Any) -> str:
"""Stable hash for non-message provider cache-key segments.
Cache-control placement and transport annotations are deliberately ignored;
semantic tool/model/thinking changes remain visible. The hash is affinity
metadata only and is never used to reconstruct or forward request content.
"""
canonical = _lineage_snapshot(_canonicalize_for_prefix_compare(value))
encoded = json.dumps(
canonical,
sort_keys=True,
ensure_ascii=False,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(encoded.encode()).hexdigest()[:24]
def extract_cache_stable_delta(
current_messages: list[dict[str, Any]],
previous_original_messages: list[dict[str, Any]] | None,
@ -251,13 +430,13 @@ def extract_cache_stable_delta(
"""
if not previous_original_messages or previous_forwarded_messages is None:
return None
relation = classify_history_relation(current_messages, previous_original_messages)
if relation.kind not in (RELATION_EXACT, RELATION_MESSAGE_APPEND):
# A same-message block append needs a block-level splice in
# ``overlay_cached_prefix``; slicing only whole messages would silently
# discard its new blocks. Rewritten tails are not append-only deltas.
return None
prefix_len = len(previous_original_messages)
if len(current_messages) < prefix_len:
return None
if _canonicalize_for_prefix_compare(
current_messages[:prefix_len]
) != _canonicalize_for_prefix_compare(previous_original_messages):
return None
return (
copy.deepcopy(previous_forwarded_messages),
copy.deepcopy(current_messages[prefix_len:]),
@ -281,12 +460,12 @@ def overlay_cached_prefix(
the corresponding leading messages so the forwarded prefix stays byte-for-byte
what the provider hashed for its cache key.
Safe only when this turn append-only-extends the previous turn (the standard
growing-conversation shape): the previous ORIGINAL messages must be an exact
prefix of the current ORIGINAL messages, and there is exactly one forwarded
message per original. Otherwise the previous forwarded bytes may not
correspond to the same positions, so we return ``optimized_messages``
unchanged (accept a possible bust rather than forward wrong content).
Safe only when this turn extends the previous turn in a proven positional
shape: either whole-message append or pure block append inside one message.
There must be exactly one previous forwarded message per original. Otherwise
the previous bytes may not correspond to the same positions, so we return
``optimized_messages`` unchanged (accept a possible bust rather than forward
wrong content).
This makes freezing byte-identical in BOTH proxy modes, so the only remaining
difference between them is how large a mutable (still-compressible) tail each
@ -311,6 +490,55 @@ def overlay_cached_prefix(
n,
)
return optimized_messages
relation = classify_history_relation(current_original_messages, prev_orig)
if relation.kind == RELATION_BLOCK_APPEND and relation.message_index is not None:
message_index = relation.message_index
if message_index < len(optimized_messages):
previous_message = prev_fwd[message_index]
previous_original_message = prev_orig[message_index]
current_message = optimized_messages[message_index]
previous_content = (
previous_message.get("content") if isinstance(previous_message, dict) else None
)
previous_original_content = (
previous_original_message.get("content")
if isinstance(previous_original_message, dict)
else None
)
current_content = (
current_message.get("content") if isinstance(current_message, dict) else None
)
split = (
len(previous_original_content)
if isinstance(previous_original_content, list)
else -1
)
if (
isinstance(previous_content, list)
and isinstance(previous_original_content, list)
and isinstance(current_content, list)
and len(previous_content) == split
and len(current_content) >= split
and _canonicalize_for_prefix_compare(current_content[:split])
== _canonicalize_for_prefix_compare(previous_original_content)
):
merged = copy.deepcopy(previous_message)
merged["content"] = copy.deepcopy(previous_content) + copy.deepcopy(
current_content[split:]
)
logger.debug(
"overlay: replayed %d forwarded blocks and appended %d new blocks "
"inside message %d",
split,
len(current_content) - split,
message_index,
)
return (
list(prev_fwd[:message_index])
+ [merged]
+ list(optimized_messages[message_index + 1 :])
)
# Append-only guard on CONTENT ONLY, message-by-message. Replay the
# previously-forwarded (cached, compressed) bytes for the longest LEADING
# run of messages that is byte-for-byte (content-canonical) identical to
@ -359,8 +587,77 @@ def overlay_cached_prefix(
return list(prev_fwd[:k]) + list(optimized_messages[k:])
_STABLE_BOUNDARY_ENV = "HEADROOM_STABLE_BOUNDARY_BREAKPOINT"
_MIN_BLOCKS_FOR_RELOCATION = 20
def _stable_boundary_enabled() -> bool:
return os.environ.get(_STABLE_BOUNDARY_ENV, "").strip().lower() not in (
"0",
"false",
"no",
"off",
)
def _breakpoint_index(
content: list[Any],
message: dict[str, Any],
message_index: int,
previous_forwarded_messages: list[dict[str, Any]] | None,
) -> int:
"""Choose newest for appends, stable-prefix end for rewritten tails."""
newest = len(content) - 1
if (
not previous_forwarded_messages
or not _stable_boundary_enabled()
or len(content) < _MIN_BLOCKS_FOR_RELOCATION
or message_index >= len(previous_forwarded_messages)
):
return newest
previous = previous_forwarded_messages[message_index]
if not isinstance(previous, dict):
return newest
relation = classify_history_relation([message], [previous])
if relation.kind != RELATION_BLOCK_REWRITE_TAIL:
return newest
logger.debug(
"cache breakpoint anchored to stable run %d/%d blocks in message %d "
"(previous=%d, stable_suffix=%d)",
relation.stable_prefix_blocks,
relation.current_block_count,
message_index,
relation.previous_block_count,
relation.stable_suffix_blocks,
)
return relation.stable_prefix_blocks - 1
def _client_marker_positions(
client_messages: list[dict[str, Any]],
) -> list[tuple[int, int, dict[str, Any]]]:
"""(message index, block index, marker) for every CLIENT cache_control.
Block-level, not one-per-message: clients mark multiple blocks within a
single long message (Claude Code does this on 1-2-message requests with a
large first message), and the ~20-block lookback applies within a message
just as it does across messages. Only block-style content carries markers.
"""
positions: list[tuple[int, int, dict[str, Any]]] = []
for i, msg in enumerate(client_messages):
content = msg.get("content") if isinstance(msg, dict) else None
if not isinstance(content, list):
continue
for bi, b in enumerate(content):
if isinstance(b, dict) and isinstance(b.get("cache_control"), dict):
positions.append((i, bi, b["cache_control"]))
return positions
def normalize_message_cache_control(
messages: list[dict[str, Any]],
previous_forwarded_messages: list[dict[str, Any]] | None = None,
client_messages: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Own message-level cache_control placement so breakpoints stay bounded.
@ -370,22 +667,35 @@ def normalize_message_cache_control(
hard-errors at **>4 cache_control blocks total** (system + tools + messages),
so on a long conversation the accumulation eventually 400s.
Fix: strip EVERY message-level cache_control and re-place a **single**
ephemeral breakpoint on the last block of the last block-style message. One
breakpoint caches the whole message prefix up to it, and because the
provider's cache key is message CONTENT, not marker presence (moving the
breakpoint forward is the documented client pattern and it hits) stripping
and re-placing markers never busts. system/tools breakpoints live outside
``messages`` and are left untouched (they still count toward the 4 limit, so
holding messages to one breakpoint leaves room for them).
Fix: strip EVERY message-level cache_control, then re-place markers at the
positions the CLIENT's current request marks (``client_messages``). The
client's positions are load-bearing, not redundant: Anthropic resolves each
breakpoint by walking back **at most ~20 content blocks** for a prior cache
entry, and agentic clients (Claude Code) keep a marker on the previous
turn's newest message precisely so the new turn's write can chain to the
old entry. Collapsing to a single newest-block marker breaks that chain
whenever one turn adds >20 blocks (typical for tool-heavy turns): the
lookback misses, the entire message history silently re-bills as cache
creation, and a marker anchored short of the final block leaves the tail
billing as fully uncached input. Mirroring the client's positions bounds
accumulation identically (the client manages its own 4-marker budget) while
preserving its read/write chaining.
Headroom owns WHERE the breakpoint goes; the client still owns WHAT it says:
the re-placed marker reuses the newest client marker verbatim, so an explicit
``ttl`` (e.g. ``"1h"``) survives consolidation instead of silently
downgrading to the 5-minute default (#2375).
The provider's cache key is message CONTENT, not marker presence (moving
the breakpoint forward is the documented client pattern and it hits), so
stripping replay leftovers and re-placing markers never busts. system/tools
breakpoints live outside ``messages`` and are left untouched.
Only block-style (list) content can carry cache_control; string content is
left as-is. Returns the input unchanged when there is nothing to normalize.
Headroom owns WHICH BLOCK carries each marker; the client owns the message
positions and the marker values, so an explicit ``ttl`` (e.g. ``"1h"``)
survives per position instead of silently downgrading (#2375). The newest
position uses stable-run anchoring for proven rewritten tails; earlier
positions go on their message's last block.
Without ``client_messages`` (or when the transformed list no longer aligns
with it), falls back to the legacy single-marker consolidation. Only
block-style (list) content can carry cache_control; string content is left
as-is. Returns the input unchanged when there is nothing to normalize.
"""
changed = False
out: list[dict[str, Any]] = []
@ -412,13 +722,75 @@ def normalize_message_cache_control(
last_block_idx = i
else:
out.append(msg)
# Re-place exactly one breakpoint on the last block-style message.
def _place(
target_idx: int,
marker: dict[str, Any],
*,
anchor: bool,
block_idx: int | None = None,
) -> bool:
msg = out[target_idx]
content = msg.get("content")
if not isinstance(content, list) or not content:
return False
content = list(content)
if block_idx is not None and 0 <= block_idx < len(content):
# Transforms can shift block indices (e.g. a dropped thinking
# block); a slightly-off placement still lands on a stable block
# in the same message, which is harmless — markers are not part
# of the provider's cache key.
breakpoint_index = block_idx
elif anchor:
breakpoint_index = _breakpoint_index(
content, msg, target_idx, previous_forwarded_messages
)
else:
breakpoint_index = len(content) - 1
# Anthropic content blocks are dictionaries, but callers can still
# supply mixed list content. Fall back to the newest block rather than
# attempting ``**`` on a scalar stable-boundary element, and skip the
# message entirely when even that is not a dict.
if not isinstance(content[breakpoint_index], dict):
breakpoint_index = len(content) - 1
if not isinstance(content[breakpoint_index], dict):
return False
content[breakpoint_index] = {**content[breakpoint_index], "cache_control": dict(marker)}
out[target_idx] = {**msg, "content": content}
return True
# Preferred: mirror the client's marker positions 1:1, block-level. The
# transform pipeline preserves message count, so index alignment is the
# invariant; fall back to legacy consolidation if it ever does not hold,
# or when the client marked nothing (legacy still places one so the
# prefix caches). The newest client marker keeps stable-run anchoring
# when the client placed it on its message's final block (intent: "cache
# through the end"); an explicit mid-message marker is honored verbatim.
if client_messages is not None and len(client_messages) == len(messages):
positions = _client_marker_positions(client_messages)
if positions:
placed_any = False
newest_mi, newest_bi, _ = positions[-1]
newest_client_content = client_messages[newest_mi].get("content")
newest_on_final_block = (
isinstance(newest_client_content, list)
and newest_bi == len(newest_client_content) - 1
)
for mi, bi, marker in positions:
is_newest = (mi, bi) == (newest_mi, newest_bi)
if is_newest and newest_on_final_block:
placed = _place(mi, marker, anchor=True)
else:
placed = _place(mi, marker, anchor=False, block_idx=bi)
placed_any = placed or placed_any
if placed_any or changed:
return out
return messages
# Legacy: re-place exactly one breakpoint on the last block-style message.
if last_block_idx >= 0:
msg = out[last_block_idx]
content = list(msg["content"])
marker = dict(last_marker) if last_marker else {"type": "ephemeral"}
content[-1] = {**content[-1], "cache_control": marker}
out[last_block_idx] = {**msg, "content": content}
_place(last_block_idx, marker, anchor=True)
changed = True
return out if changed else messages
@ -829,6 +1201,10 @@ class SessionTrackerStore:
# value, so a synthetic key can never collide with a client-supplied
# x-headroom-session-id.
self._lineages: dict[str, OrderedDict[str, list[Any]]] = {}
# Exact non-message cache-key affinity per tracker. Anthropic renders
# tools before system/messages, so two sub-calls with identical history
# but different tool profiles must never share frozen-prefix state.
self._lineage_affinities: dict[str, str | None] = {}
self._lineage_counter = itertools.count(1)
def get_or_create(self, session_id: str, provider: str) -> PrefixCacheTracker:
@ -854,6 +1230,7 @@ class SessionTrackerStore:
session_id: str,
provider: str,
messages: list[dict[str, Any]] | None = None,
cache_affinity: str | None = None,
) -> PrefixCacheTracker:
"""Resolve the tracker for THIS conversation within a session id (#2085).
@ -866,10 +1243,11 @@ class SessionTrackerStore:
Lineage resolution keys trackers by conversation content instead:
reuse the tracker whose previous request messages are a prefix of the
incoming history (client histories are append-only, so a
conversation's next request always extends its previous one); start a
fresh lineage when the history diverges or was rewritten (client-side
compaction the provider cache line is gone then anyway).
incoming history. It also recognizes a conservative block-level shape
where a large leading run and two-block identity suffix survive while
the middle tail is regenerated; all other rewrites start a fresh
lineage. This keeps #2671's stable cache boundary attached without
merging unrelated parallel sub-calls.
Byte-identical histories (templated fan-outs before they diverge)
intentionally share a tracker: their provider cache line is identical
too, so sharing is harmless.
@ -888,6 +1266,9 @@ class SessionTrackerStore:
compares like against like across turns. ``None``/empty
(legacy callers, stub stores in tests) falls back to plain
:meth:`get_or_create`.
cache_affinity: Stable fingerprint of the provider's non-message
cache-key segments (model/tools/tool choice/thinking). Lineages
with different affinity never share a tracker.
Returns:
The ``PrefixCacheTracker`` for this conversation's lineage.
@ -918,14 +1299,48 @@ class SessionTrackerStore:
family = self._lineages.setdefault(session_id, OrderedDict())
# Longest recorded chain that prefixes the incoming history wins.
# Strict whole-message continuations win first, then pure block appends.
# Rewritten-tail matches are deliberately last and require a unique best
# structural score; ambiguity starts a fresh lineage instead of making
# sibling sub-calls ping-pong one tracker.
by_length = sorted(family.items(), key=lambda item: len(item[1]), reverse=True)
best_key: str | None = None
best_len = -1
for key, chain in family.items():
if len(chain) > len(snap) or len(chain) <= best_len:
continue
if snap[: len(chain)] == chain:
best_key, best_len = key, len(chain)
for accepted in (
(RELATION_EXACT, RELATION_MESSAGE_APPEND),
(RELATION_BLOCK_APPEND,),
):
for key, chain in by_length:
if self._lineage_affinities.get(key) != cache_affinity:
continue
relation = _classify_history_canonical(snap, chain)
if relation.kind in accepted:
best_key = key
break
if best_key is not None:
break
if best_key is None:
rewrite_candidates: list[tuple[tuple[int, int, int], str]] = []
for key, chain in by_length:
if self._lineage_affinities.get(key) != cache_affinity:
continue
relation = _classify_history_canonical(snap, chain)
if relation.kind == RELATION_BLOCK_REWRITE_TAIL:
rewrite_candidates.append(
(
(
relation.stable_prefix_blocks,
relation.stable_suffix_blocks,
relation.previous_block_count,
),
key,
)
)
rewrite_candidates.sort(reverse=True)
if rewrite_candidates and (
len(rewrite_candidates) == 1 or rewrite_candidates[0][0] != rewrite_candidates[1][0]
):
best_key = rewrite_candidates[0][1]
if best_key is None:
cap = self._default_config.max_lineages_per_session
@ -963,6 +1378,7 @@ class SessionTrackerStore:
# the family before the stamp below.
tracker = self.get_or_create(best_key, provider)
family[best_key] = snap
self._lineage_affinities[best_key] = cache_affinity
return tracker
def compute_session_id(
@ -1031,6 +1447,7 @@ class SessionTrackerStore:
family = self._lineages[base]
for key in [k for k in family if k not in self._trackers]:
del family[key]
self._lineage_affinities.pop(key, None)
if not family:
del self._lineages[base]
logger.debug("SessionTrackerStore: cleaned up %d expired sessions", len(expired))

View file

@ -0,0 +1,83 @@
"""Inline resolution of ``<<ccr:...>>`` markers on the response path.
Normal CCR resolution relies on the ``headroom_retrieve`` tool: a marker is
redeemed when the model calls the tool back. That path assumes there's a
subsequent turn in which the model *can* call it. Callers that never see an
injected tool at all e.g. Headroom running as a LiteLLM guardrail/proxy hop
with no tool-call turn in between (#2509) — have no way to redeem a marker,
so it leaks through as raw text.
This module provides an explicit, opt-in fallback (``--ccr-inline-resolve``):
scan the outgoing response for markers and substitute the original content
directly, instead of leaving the marker for the model to redeem later.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from ..cache.compression_store import (
CompressionStore,
format_retrieval_miss_detail,
get_compression_store,
)
logger = logging.getLogger(__name__)
# Matches the opaque-blob marker form `<<ccr:HASH,KIND,SIZE>>` (and the
# row-offload form `<<ccr:HASH N_rows_offloaded>>`) emitted by SmartCrusher.
# HASH is 12-24 hex chars; see headroom/ccr/tool_injection.py for the same
# constant used on the injection side.
_MARKER_RE = re.compile(r"<<ccr:([a-f0-9]{12,24})[^>]*>>")
def resolve_markers_in_text(text: str, *, store: CompressionStore | None = None) -> str:
"""Replace every ``<<ccr:HASH,...>>`` marker in ``text`` with its original content.
A miss (expired/evicted/unknown hash) can't be reported back to the
model on this path there's no tool-call round-trip — so the marker is
left in place with the miss reason appended rather than raising.
"""
if "<<ccr:" not in text:
return text
resolved_store = store or get_compression_store()
def _replace(match: re.Match[str]) -> str:
hash_key = match.group(1)
entry = resolved_store.retrieve(hash_key)
if entry is not None:
original = entry.original_content
return original if isinstance(original, str) else json.dumps(original)
get_status = getattr(resolved_store, "get_entry_status", None)
status = get_status(hash_key, clean_expired=True) if callable(get_status) else None
detail = format_retrieval_miss_detail(status) if status else "entry not found"
logger.warning(f"CCR inline-resolve: marker {hash_key} unresolvable ({detail})")
return f"{match.group(0)} [unresolved: {detail}]"
return _MARKER_RE.sub(_replace, text)
def resolve_markers_in_response(response: Any, *, store: CompressionStore | None = None) -> Any:
"""Recursively resolve ``<<ccr:...>>`` markers in every string field of a payload.
Walks the full response structure rather than picking out
provider-specific fields (``content`` blocks, ``message.content``,
Responses-API ``output`` items, ...) so it stays correct regardless of
where a marker ends up, and doesn't need per-provider maintenance.
"""
resolved_store = store or get_compression_store()
if isinstance(response, str):
return resolve_markers_in_text(response, store=resolved_store)
if isinstance(response, list):
return [resolve_markers_in_response(item, store=resolved_store) for item in response]
if isinstance(response, dict):
return {
key: resolve_markers_in_response(value, store=resolved_store)
for key, value in response.items()
}
return response

View file

@ -58,6 +58,7 @@ class CCRToolResult:
content: str
success: bool
items_retrieved: int = 0
tool_name: str | None = None
@dataclass
@ -211,6 +212,7 @@ class CCRResponseHandler:
tool_call_id=ccr_call.tool_call_id,
content=content,
success=False,
tool_name=ccr_call.tool_name,
)
# Retrieval is by hash: always return the full original content.
@ -229,6 +231,7 @@ class CCRResponseHandler:
content=content,
success=True,
items_retrieved=entry.original_item_count,
tool_name=ccr_call.tool_name,
)
miss_status = (
@ -249,6 +252,7 @@ class CCRResponseHandler:
tool_call_id=ccr_call.tool_call_id,
content=content,
success=False,
tool_name=ccr_call.tool_name,
)
except Exception as e:
@ -264,6 +268,7 @@ class CCRResponseHandler:
tool_call_id=ccr_call.tool_call_id,
content=content,
success=False,
tool_name=ccr_call.tool_name,
)
def _create_tool_result_message(
@ -337,14 +342,13 @@ class CCRResponseHandler:
response_data = json.loads(result.content)
except json.JSONDecodeError:
response_data = {"content": result.content}
parts.append(
{
"functionResponse": {
"name": result.tool_call_id, # tool_call_id contains the function name for Google
"response": response_data,
}
}
)
function_response = {
"name": result.tool_name or result.tool_call_id,
"response": response_data,
}
if result.tool_name and result.tool_call_id != result.tool_name:
function_response["id"] = result.tool_call_id
parts.append({"functionResponse": function_response})
return {
"role": "user",
"parts": parts,
@ -399,7 +403,16 @@ class CCRResponseHandler:
# echoed back verbatim as `input[]` items — not a single
# role/content dict like chat completions. Sentinel key mirrors
# `_openai_tool_results`; handle_response() extends on it.
return {"_openai_responses_output_items": response.get("output", [])}
# `.get("output", [])` only falls back when the key is absent, so a
# present-but-null `output` would return None and make the
# `current_messages.extend(...)` in handle_response raise TypeError;
# coerce to a list like the choices branch above.
output_items = response.get("output")
return {
"_openai_responses_output_items": output_items
if isinstance(output_items, list)
else []
}
elif provider == "google":
# Google/Gemini format: role is "model", content is in candidates[0].content.parts
candidates = response.get("candidates", [])
@ -901,8 +914,17 @@ class StreamingCCRHandler:
if "content" in delta and delta["content"]:
message["content"] = (message.get("content") or "") + delta["content"]
if "tool_calls" in delta:
for tc_delta in delta["tool_calls"]:
# Guard the value, not just the key: some OpenAI-compatible
# providers include ``"tool_calls": null`` (and ``"function": null``)
# in a delta rather than omitting the key, which would make the
# iteration below raise ``TypeError: 'NoneType' object is not
# iterable`` and abort the whole reconstruction. Mirrors the
# ``and delta["content"]`` value-guard above.
tool_calls = delta.get("tool_calls")
if isinstance(tool_calls, list):
for tc_delta in tool_calls:
if not isinstance(tc_delta, dict):
continue
idx = tc_delta.get("index", 0)
if idx not in tool_calls_map:
tool_calls_map[idx] = {
@ -914,8 +936,8 @@ class StreamingCCRHandler:
tc = tool_calls_map[idx]
if "id" in tc_delta:
tc["id"] = tc_delta["id"]
if "function" in tc_delta:
fn = tc_delta["function"]
fn = tc_delta.get("function")
if isinstance(fn, dict):
if "name" in fn:
tc["function"]["name"] = fn["name"]
if "arguments" in fn:

View file

@ -14,6 +14,7 @@ class CCRToolCall:
tool_call_id: str
hash_key: str
tool_name: str | None = None
def extract_tool_calls(response: dict[str, Any], provider: str) -> list[dict[str, Any]]:
@ -88,6 +89,8 @@ def tool_call_id_for_provider(tool_call: dict[str, Any], provider: str) -> str:
if provider == "google":
function_call = tool_call.get("functionCall", {})
if isinstance(function_call, dict):
if function_call.get("id"):
return str(function_call["id"])
name = function_call.get("name", CCR_TOOL_NAME)
return str(name)
return CCR_TOOL_NAME
@ -111,11 +114,14 @@ def parse_ccr_tool_calls(
other_calls.append(tool_call)
continue
tool_name = None
tool_call_id = tool_call_id_for_provider(tool_call, provider)
if provider == "google":
function_call = tool_call.get("functionCall", {})
if isinstance(function_call, dict) and function_call.get("id"):
tool_name = str(function_call.get("name", CCR_TOOL_NAME))
ccr_calls.append(
CCRToolCall(
tool_call_id=tool_call_id_for_provider(tool_call, provider),
hash_key=hash_key,
)
CCRToolCall(tool_call_id=tool_call_id, hash_key=hash_key, tool_name=tool_name)
)
return ccr_calls, other_calls

View file

@ -16,12 +16,24 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Protocol, runtime_checkable
# Tool name constant - used for matching tool calls
CCR_TOOL_NAME = "headroom_retrieve"
@runtime_checkable
class _HashOwnershipStore(Protocol):
"""Structural type for verify_ownership()'s store dependency.
Only needs the existence check matches CompressionStore.exists()
without coupling this module to the concrete cache implementation
(or requiring test doubles to subclass it).
"""
def exists(self, hash_key: str, clean_expired: bool = False) -> bool: ...
def create_ccr_tool_definition(
provider: str = "anthropic",
) -> dict[str, Any]:
@ -170,6 +182,11 @@ class CCRToolInjector:
inject_tool: bool = True
inject_system_instructions: bool = True
retrieval_endpoint: str = "/v1/retrieve"
# Store used to verify a scanned marker's hash is actually ours before
# advertising it (issue #2836). None resolves lazily to
# get_compression_store() — request-scoped store if one is set, else the
# global singleton — matching how every other CCR call site resolves it.
compression_store: _HashOwnershipStore | None = None
# Detected compression markers
_detected_hashes: list[str] = field(default_factory=list)
@ -281,7 +298,16 @@ class CCRToolInjector:
return self._detected_hashes
def _scan_text(self, text: str) -> None:
"""Scan text for compression markers from any compressor."""
"""Scan text for compression markers from any compressor.
Shape-only: this matches the bracket format any compressor (or,
as it turns out, any *other* context tool) can produce. Callers
that need to know the hash is actually ours i.e. before
advertising it to the model via the retrieve tool must call
:meth:`verify_ownership` afterward. Kept separate so this method
stays a pure, store-independent text scan (that's what the
existing marker-format test suite exercises).
"""
for pattern in self._marker_patterns:
matches = pattern.findall(text)
for match in matches:
@ -293,6 +319,59 @@ class CCRToolInjector:
if hash_key and hash_key not in self._detected_hashes:
self._detected_hashes.append(hash_key)
def verify_ownership(self, store: _HashOwnershipStore | None = None) -> list[str]:
"""Drop any detected hash the compression store doesn't recognize.
The bracket-marker shape (``[... hash=...]``) is not unique to
Headroom other context tools emit visually identical markers.
Matching shape alone (what :meth:`scan_for_markers` does) adopts
their hashes too: ``has_compressed_content`` goes true and the
retrieve tool + "Available hashes" instruction get injected for a
hash this proxy never stored. The model then calls
``headroom_retrieve``, gets a guaranteed miss, and re-does the work
it already had (issue #2836).
Call this after :meth:`scan_for_markers` and before checking
``has_compressed_content`` / injecting the tool. Uses the same
``store.exists()`` check the retrieve endpoint itself performs, so
a hash that survives this filter is provably redeemable right now
(or, if it expires between this check and the model's next call,
fails the same way a genuinely-ours stale hash already would
this only removes hashes that were never ours to begin with).
Args:
store: Compression store to verify against. Defaults to
``get_compression_store()`` (request-scoped if set, else
the global singleton) the same resolution every other
CCR call site uses.
Returns:
The filtered ``detected_hashes`` list (also updates
``self.detected_hashes`` in place).
"""
if not self._detected_hashes:
return self._detected_hashes
if store is None:
store = self.compression_store
if store is None:
from headroom.cache.compression_store import get_compression_store
store = get_compression_store()
def _safe_exists(hash_key: str) -> bool:
try:
return store.exists(hash_key)
except Exception:
# A store lookup failure must not make CCR verification
# blow up the request; treat as "not ours" (drop the
# marker) — the safe direction, since a dropped real
# marker just means the model can't use the retrieve tool
# for it this turn, the same failure mode as CCR being off.
return False
self._detected_hashes = [h for h in self._detected_hashes if _safe_exists(h)]
return self._detected_hashes
def inject_tool_definition(
self,
tools: list[dict[str, Any]] | None,
@ -437,6 +516,10 @@ class CCRToolInjector:
tool_was_injected is False if tool was already present (e.g., from MCP).
"""
self.scan_for_markers(messages)
# Shape-only scanning also matches markers from other context tools;
# drop hashes this proxy never actually stored before they can
# drive tool injection (issue #2836).
self.verify_ownership()
if not (self.has_compressed_content or session_has_done_ccr):
return messages, tools, False

View file

@ -25,6 +25,7 @@ from . import ( # noqa: F401
perf,
proxy,
recover,
rollout,
tools,
update,
wrap,

View file

@ -30,6 +30,8 @@ from headroom.paths import savings_path
from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
claude_auth_conflict_message,
claude_auth_conflict_sources,
detect_claude_code_version,
is_custom_anthropic_base_url,
remote_control_applies_to_auth,
@ -48,6 +50,13 @@ SKIP = "skip"
_LOOPBACK_URL_RE = re.compile(r"https?://(?:127\.0\.0\.1|localhost):(\d+)")
_CODEX_BASE_URL_RE = re.compile(r'base_url\s*=\s*"https?://(?:127\.0\.0\.1|localhost):(\d+)')
# Ollama's fixed default port. `ollama launch claude` writes
# ``ANTHROPIC_BASE_URL=http://127.0.0.1:11434`` into the launched Claude Code
# child, which outranks the persistent-install env block and silently bypasses
# the Headroom proxy (issue #2199). Recognized so the routing diagnostic names
# the collision instead of telling the user to re-probe port 11434.
_OLLAMA_DEFAULT_PORT = 11434
@dataclass
class CheckResult:
@ -182,6 +191,39 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
return _classify_routing_url(name, base_url, port, source=str(settings_path))
def check_claude_auth_conflict(
settings_path: Path,
project_settings_path: Path,
project_local_settings_path: Path,
environ: Mapping[str, str],
) -> CheckResult | None:
"""Report contradictory effective Claude credentials without their values."""
def settings_env(path: Path) -> dict[str, object]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
env = payload.get("env") if isinstance(payload, dict) else None
return dict(env) if isinstance(env, dict) else {}
conflict = claude_auth_conflict_sources(
(str(settings_path), settings_env(settings_path)),
(str(project_settings_path), settings_env(project_settings_path)),
(str(project_local_settings_path), settings_env(project_local_settings_path)),
("shell environment", environ),
)
if conflict is None:
return None
return CheckResult(
name="claude auth",
status=FAIL,
summary=claude_auth_conflict_message(conflict),
)
def check_claude_remote_control_gate(
settings_path: Path,
environ: Mapping[str, str],
@ -344,6 +386,22 @@ def _classify_routing_url(name: str, url: str, port: int, *, source: str) -> Che
)
found_port = int(match.group(1))
if found_port != port:
if found_port == _OLLAMA_DEFAULT_PORT:
# Not a mis-probed Headroom port — this is Ollama's endpoint, so
# `headroom doctor --port 11434` would only chase a red herring.
return CheckResult(
name=name,
status=WARN,
summary=(
f"points at Ollama ({url}), not the Headroom proxy ({source}) — "
"`ollama launch claude` bypasses the persistent Headroom route"
),
hint=(
"both claim ANTHROPIC_BASE_URL; run Ollama-backed sessions "
"through Headroom by chaining the proxy at its Ollama upstream "
"(see issue #2199)"
),
)
return CheckResult(
name=name,
status=WARN,
@ -544,16 +602,26 @@ def doctor(port: int, emit_json: bool) -> None:
stats = probe_json(f"{base_url}/stats", timeout=5.0) if livez else None
installed = get_version()
project_claude_settings = Path.cwd() / ".claude" / "settings.json"
project_local_claude_settings = Path.cwd() / ".claude" / "settings.local.json"
checks = [
check_proxy_liveness(livez, base_url),
check_version_drift(livez, installed),
check_claude_routing(claude_settings_path(), port),
check_wrap_marker_staleness(Path.cwd() / ".claude" / "settings.local.json"),
check_wrap_marker_staleness(project_local_claude_settings),
check_codex_routing(codex_config_path(), port),
check_shell_env(os.environ, port),
check_savings(stats, savings_path()),
check_budget(stats),
]
auth_conflict_check = check_claude_auth_conflict(
claude_settings_path(),
project_claude_settings,
project_local_claude_settings,
os.environ,
)
if auth_conflict_check is not None:
checks.append(auth_conflict_check)
# Lazy resolver: `claude --version` is a Node CLI subprocess (seconds of
# cold start, 10s worst-case timeout) — only pay for it when the RC gate
# is actually plausible (custom base URL + subscription auth).

View file

@ -41,6 +41,7 @@ from headroom.install.runtime import (
from headroom.install.state import ManifestError, load_manifest, save_manifest
from headroom.install.supervisors import start_supervisor
from headroom.providers.claude import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV
from headroom.providers.claude.runtime import TOOL_SEARCH_FOUNDRY_DEFAULT
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from headroom.providers.codex.threads import retag_to_headroom
@ -181,7 +182,12 @@ def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None:
# all into its context window — overflowing it (breaks sub-agent spawns,
# forces constant compaction). Keep deferral on; respect a user-set value.
# Shares the TOOL_SEARCH_* constants with `wrap` and `install`.
env_map.setdefault(TOOL_SEARCH_ENV, TOOL_SEARCH_DEFAULT)
tool_search_default = (
TOOL_SEARCH_FOUNDRY_DEFAULT
if os.environ.get("CLAUDE_CODE_USE_FOUNDRY")
else TOOL_SEARCH_DEFAULT
)
env_map.setdefault(TOOL_SEARCH_ENV, tool_search_default)
payload["env"] = env_map
hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}

View file

@ -11,6 +11,7 @@ from copy import deepcopy
from dataclasses import dataclass
import click
from click.core import ParameterSource
from headroom._subprocess import run
from headroom.install.health import probe_json, probe_ready
@ -36,6 +37,7 @@ from headroom.install.runtime import (
from headroom.install.state import (
ManifestError,
delete_manifest,
list_manifests,
load_manifest,
save_manifest,
)
@ -65,14 +67,82 @@ def install() -> None:
"""Install and manage persistent Headroom deployments."""
def _profile_selection_was_explicit() -> bool:
"""True when the current command received an explicit ``--profile``.
An explicit selection must be honored verbatim or rejected, never redirected
to ``HEADROOM_DEPLOYMENT_PROFILE`` or a lone installed deployment: silently
operating ``stop``/``restart``/``remove`` on a different profile than the one
the user typed is dangerous. Only a defaulted (omitted) ``--profile`` is
eligible for the recovery fallback. Outside a Click command context (direct
calls / unit tests) there is no explicit selection to protect.
"""
ctx = click.get_current_context(silent=True)
if ctx is None:
return False
return bool(ctx.get_parameter_source("profile") == ParameterSource.COMMANDLINE)
def _missing_profile_error(
name: str,
installed: list[DeploymentManifest],
*,
source: str | None = None,
) -> click.ClickException:
if installed:
names = ", ".join(sorted(m.profile for m in installed))
hint = f" Installed: {names}. Select one with --profile <name>."
else:
hint = " No deployments are installed; run `headroom init` or `headroom install apply`."
origin = f" (from {source})" if source else ""
return click.ClickException(f"No deployment profile named '{name}'{origin} is installed.{hint}")
def _require_manifest(profile: str) -> DeploymentManifest:
try:
manifest = load_manifest(profile)
except ManifestError as e:
raise click.ClickException(str(e)) from None
if manifest is None:
raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
return manifest
if manifest is not None:
return manifest
# The requested profile isn't installed. `headroom init` installs under a
# non-"default" profile name (e.g. init-user), while every lifecycle command
# defaults --profile to "default" -- so on an init'd machine the documented
# bare commands (`headroom install status`, etc.) would all dead-end (#2811).
installed = list_manifests()
# An EXPLICIT --profile is honored or rejected verbatim, never redirected: a
# typo must not silently act on the env/lone profile (#2832 review).
if _profile_selection_was_explicit():
raise _missing_profile_error(profile, installed)
# --profile was defaulted. A non-empty HEADROOM_DEPLOYMENT_PROFILE (which the
# runtime exports) is itself an explicit selection: honor it when installed,
# otherwise fail naming it. It must never fall through to the lone-manifest
# fallback and silently operate on a different deployment (#2832 review).
env_profile = os.environ.get("HEADROOM_DEPLOYMENT_PROFILE", "").strip()
if env_profile:
if env_profile != profile:
try:
resolved = load_manifest(env_profile)
except ManifestError:
resolved = None
if resolved is not None:
return resolved
raise _missing_profile_error(env_profile, installed, source="HEADROOM_DEPLOYMENT_PROFILE")
# Neither CLI nor environment named a profile. A single installed deployment
# is unambiguous, so use it; otherwise report what is available.
if len(installed) == 1:
return installed[0]
raise _missing_profile_error(profile, installed)
def _is_windows() -> bool:
"""Return whether this command is running on Windows."""
return sys.platform.startswith("win")
def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = False) -> None:
@ -495,7 +565,8 @@ def _echo_installed(manifest: DeploymentManifest, *, prefix: str = "Installed pe
is_flag=True,
help=(
"Opt in to tool_result interceptors (ast-grep Read outliner, etc.) in the "
"persistent runtime. Off by default while this feature ships."
"persistent runtime. This also selects the required canary rollout channel "
"unless --env HEADROOM_ROLLOUT_CHANNEL=... is supplied."
),
)
@click.option(
@ -593,6 +664,16 @@ def install_apply(
bedrock_profile=bedrock_profile,
extra_env=combined_env,
)
if (
preset == InstallPreset.PERSISTENT_SERVICE.value
and manifest.preset == InstallPreset.PERSISTENT_TASK.value
and _is_windows()
):
click.echo(
"Warning: persistent-service is not supported on Windows because the "
"Python runner cannot act as a Windows service. Falling back to "
"persistent-task with Task Scheduler."
)
_apply_manifest(manifest)
_echo_installed(manifest)

View file

@ -400,8 +400,9 @@ def _activate_output_shaper(port: int | None = None) -> tuple[str, int]:
When a proxy is already running locally we hot-enable it via
``/admin/runtime-env`` (no restart, the same channel ``wrap`` uses), so
``--apply`` actually takes effect. Returns ``(status, port)`` where status is
``"live"`` (enabled on a running proxy), ``"absent"`` (no reachable proxy),
or ``"error"``.
``"live"`` (enabled on a running proxy), ``"blocked"`` (the proxy's
rollout channel rejected it), ``"absent"`` (no reachable proxy), or
``"error"``.
"""
import json as _json
import os as _os
@ -417,7 +418,22 @@ def _activate_output_shaper(port: int | None = None) -> tuple[str, int]:
)
try:
with urllib.request.urlopen(request, timeout=2) as response:
response.read()
raw_response = response.read()
payload = _json.loads(raw_response) if raw_response else {}
rollout = payload.get("rollout") if isinstance(payload, dict) else None
if isinstance(rollout, dict):
decisions = rollout.get("features")
if isinstance(decisions, list):
output_shaper = next(
(
item
for item in decisions
if isinstance(item, dict) and item.get("name") == "proxy_output_shaper"
),
None,
)
if isinstance(output_shaper, dict) and not output_shaper.get("enabled", False):
return "blocked", resolved_port
return "live", resolved_port
except (urllib.error.URLError, OSError):
# ConnectionRefused (no proxy) or 404 (proxy predates the endpoint).
@ -555,8 +571,17 @@ def _run_verbosity(
f"level {best_profile.level} is live now (while HEADROOM_VERBOSITY_LEVEL is unset)."
)
click.echo(
" To keep it on across restarts: export HEADROOM_OUTPUT_SHAPER=1 "
"before `headroom wrap ...` (wrap pushes it to the proxy)."
" To keep it on across restarts: export HEADROOM_ROLLOUT_CHANNEL=beta "
"and HEADROOM_OUTPUT_SHAPER=1 before `headroom wrap ...`."
)
elif status == "blocked":
click.echo(
"\n ⚠ Level written, but the running proxy's rollout channel blocks the "
"beta output shaper."
)
click.echo(
" Restart it with HEADROOM_ROLLOUT_CHANNEL=beta and "
"HEADROOM_OUTPUT_SHAPER=1; the learned level will be used automatically."
)
else:
click.echo(
@ -564,9 +589,10 @@ def _run_verbosity(
"NOT shaping output yet."
)
click.echo(
" Enable it: export HEADROOM_OUTPUT_SHAPER=1 then `headroom wrap ...` "
"(or start `headroom proxy` with it set). The learned level is then used "
"automatically while HEADROOM_VERBOSITY_LEVEL is unset."
" Enable it: export HEADROOM_ROLLOUT_CHANNEL=beta and "
"HEADROOM_OUTPUT_SHAPER=1, then run `headroom wrap ...` (or restart "
"`headroom proxy`). The learned level is then used automatically while "
"HEADROOM_VERBOSITY_LEVEL is unset."
)
else:
click.echo("\n Dry run — use --apply to persist the level and baseline.")

View file

@ -76,6 +76,7 @@ def _register_commands() -> None:
perf, # noqa: F401
proxy, # noqa: F401
recover, # noqa: F401
rollout, # noqa: F401
savings, # noqa: F401
tools, # noqa: F401
update, # noqa: F401

View file

@ -30,6 +30,8 @@ from ._utils.formatting import (
from ._utils.parsers import parse_duration
from .main import main
_REINDEX_PAGE_SIZE = 1_000
def _default_db_path() -> str:
"""Resolve the memory DB the proxy/install actually use.
@ -66,6 +68,148 @@ def get_store(db_path: str) -> SQLiteMemoryStore:
return SQLiteMemoryStore(db_path)
def _sqlite_table_exists(conn: Any, table_name: str) -> bool:
"""Return whether a SQLite table or virtual table has been initialized."""
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return row is not None
def _remove_from_search_indexes(db_path: str, memory_ids: list[str]) -> bool:
"""Remove specific memories from FTS5 and vector search indexes.
FTS5 cleanup uses a bare sqlite3 connection (FTS5 is built-in).
Vector cleanup requires sqlite-vec to load the vec0 virtual-table
module; when not installed a warning is printed.
Returns True when both indexes were fully synced, False when any part
of the sync failed. Callers must treat False as a partial failure and
surface it typically by exiting with a non-zero code so the primary
store mutation is not silently reported as fully successful.
"""
if not memory_ids:
return True
import sqlite3
db = Path(db_path)
ok = True
# FTS5 table lives in the same memory.db file (built-in, no extension needed).
try:
with sqlite3.connect(str(db)) as conn:
if _sqlite_table_exists(conn, "memory_fts"):
for i in range(0, len(memory_ids), 500):
chunk = memory_ids[i : i + 500]
placeholders = ",".join("?" * len(chunk))
conn.execute(
f"DELETE FROM memory_fts WHERE memory_id IN ({placeholders})",
chunk,
)
conn.commit()
except Exception as exc:
print_warning(f"FTS5 index cleanup incomplete: {exc}")
ok = False
# Vector DB is a sibling file: memory.db -> memory_vectors.db.
# vec_embeddings is a vec0 virtual table — the sqlite-vec extension must be
# loaded on every connection before touching it.
vector_db = db.parent / f"{db.stem}_vectors.db"
if not vector_db.exists():
return ok
try:
with sqlite3.connect(str(vector_db)) as conn:
# A sibling database may exist before the optional vector index has
# ever been initialized. That is a valid no-op, not a sync failure.
if not _sqlite_table_exists(conn, "vec_metadata"):
return ok
try:
import sqlite_vec
except ImportError:
print_warning(
"sqlite-vec is not installed; stale vector index entries may remain. "
"Run 'headroom memory reindex' after installing sqlite-vec to repair."
)
return False
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
for i in range(0, len(memory_ids), 500):
chunk = memory_ids[i : i + 500]
placeholders = ",".join("?" * len(chunk))
rows = conn.execute(
f"SELECT rowid FROM vec_metadata WHERE memory_id IN ({placeholders})",
chunk,
).fetchall()
rowids = [r[0] for r in rows]
if rowids:
rph = ",".join("?" * len(rowids))
conn.execute(f"DELETE FROM vec_embeddings WHERE rowid IN ({rph})", rowids)
conn.execute(f"DELETE FROM vec_metadata WHERE rowid IN ({rph})", rowids)
conn.commit()
except Exception as exc:
print_warning(f"Vector index cleanup incomplete: {exc}")
ok = False
return ok
def _clear_all_search_indexes(db_path: str) -> bool:
"""Truncate both search indexes after a full purge.
Same extension-loading requirement as :func:`_remove_from_search_indexes`.
Returns True on full success, False on any partial failure.
"""
import sqlite3
db = Path(db_path)
ok = True
try:
with sqlite3.connect(str(db)) as conn:
if _sqlite_table_exists(conn, "memory_fts"):
conn.execute("DELETE FROM memory_fts")
conn.commit()
except Exception as exc:
print_warning(f"FTS5 index cleanup incomplete: {exc}")
ok = False
vector_db = db.parent / f"{db.stem}_vectors.db"
if not vector_db.exists():
return ok
try:
with sqlite3.connect(str(vector_db)) as conn:
if not _sqlite_table_exists(conn, "vec_metadata"):
return ok
try:
import sqlite_vec
except ImportError:
print_warning(
"sqlite-vec is not installed; stale vector index entries may remain. "
"Run 'headroom memory reindex' after installing sqlite-vec to repair."
)
return False
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
conn.execute("DELETE FROM vec_embeddings")
conn.execute("DELETE FROM vec_metadata")
conn.commit()
except Exception as exc:
print_warning(f"Vector index cleanup incomplete: {exc}")
ok = False
return ok
def _resolve_memory(store: SQLiteMemoryStore, memory_id: str) -> Memory:
"""Resolve an exact or unambiguous partial memory ID."""
memory = asyncio.run(store.get(memory_id))
@ -620,13 +764,37 @@ def edit_memory(
mem = matches[0]
# Update fields
content_changed = content is not None and content != mem.content
if content is not None:
mem.content = content
if importance is not None:
mem.importance = importance
index_ok = True
if content_changed:
# Clear the stale embedding so the memory MCP server re-embeds on
# next startup. Also remove the old FTS5 and vector index entries
# now to avoid serving stale search results until then.
mem.embedding = None
index_ok = _remove_from_search_indexes(db_path, [mem.id])
# Re-index FTS5 immediately with new content (no embedder needed).
try:
from ..memory.adapters.fts5 import FTS5TextIndex
fts = FTS5TextIndex(db_path=db_path)
asyncio.run(fts.index_memory(mem))
except Exception as exc:
print_warning(f"FTS5 re-index incomplete: {exc}")
index_ok = False
# Save
asyncio.run(store.save(mem))
if not index_ok:
print_warning(
f"Updated memory {mem.id[:8]}, but search index sync incomplete. "
"Run 'headroom memory reindex' to repair."
)
sys.exit(1)
print_success(f"Updated memory {mem.id[:8]}")
except Exception as e:
@ -775,7 +943,14 @@ def delete_memories(
# Delete
deleted = asyncio.run(store.delete_batch(resolved_ids))
print_success(f"Deleted {deleted} memory(ies).")
if _remove_from_search_indexes(db_path, resolved_ids):
print_success(f"Deleted {deleted} memory(ies).")
else:
print_warning(
f"Deleted {deleted} memory(ies) from store, but search index sync "
"incomplete. Run 'headroom memory reindex' to repair."
)
sys.exit(1)
except click.Abort:
click.echo("Aborted.")
@ -897,7 +1072,14 @@ def prune_memories(
# Delete
ids_to_delete = [m.id for m in memories]
deleted = asyncio.run(store.delete_batch(ids_to_delete))
print_success(f"Deleted {deleted} memory(ies).")
if _remove_from_search_indexes(db_path, ids_to_delete):
print_success(f"Deleted {deleted} memory(ies).")
else:
print_warning(
f"Deleted {deleted} memory(ies) from store, but search index sync "
"incomplete. Run 'headroom memory reindex' to repair."
)
sys.exit(1)
except click.BadParameter as e:
print_error(str(e))
@ -952,7 +1134,14 @@ def purge_memories(ctx: click.Context, db_path: str, confirm_flag: bool) -> None
# Purge
deleted = asyncio.run(store.clear_all())
print_success(f"Purged {deleted} memory(ies).")
if _clear_all_search_indexes(db_path):
print_success(f"Purged {deleted} memory(ies).")
else:
print_warning(
f"Purged {deleted} memory(ies) from store, but search index sync "
"incomplete. Run 'headroom memory reindex' to repair."
)
sys.exit(1)
except click.Abort:
click.echo("Aborted.")
@ -962,6 +1151,143 @@ def purge_memories(ctx: click.Context, db_path: str, confirm_flag: bool) -> None
sys.exit(1)
@memory.command("reindex")
@db_path_option
@click.pass_context
def reindex_memories(ctx: click.Context, db_path: str) -> None:
"""Rebuild FTS5 search index and remove orphaned vector entries.
Use this to repair an inconsistent index after a failed delete, prune,
or purge. Run it after installing sqlite-vec to clean up any vector
entries that could not be removed earlier.
Vector embeddings are not regenerated by this command they are rebuilt
automatically when the Headroom server next starts.
\b
Example:
headroom memory reindex
"""
import sqlite3
store = get_store(db_path)
try:
# Page through the complete active store. A fixed cap is destructive:
# clearing FTS and rebuilding only the first N rows drops valid search
# coverage, while using the same truncated ID set for vector cleanup
# misclassifies later primary rows as orphans.
memories: list[Memory] = []
offset = 0
while True:
page = asyncio.run(
store.query(
MemoryFilter(
limit=_REINDEX_PAGE_SIZE,
offset=offset,
order_by="created_at",
order_desc=False,
)
)
)
if not page:
break
memories.extend(page)
offset += len(page)
db = Path(db_path)
ok = True
# --- FTS5: wipe and rebuild from primary store ---
from ..memory.adapters.fts5 import FTS5TextIndex
# Construction initializes an absent optional FTS table. Cleanup
# helpers, by contrast, intentionally treat an absent table as a no-op.
fts = FTS5TextIndex(db_path=db_path)
try:
with sqlite3.connect(str(db)) as conn:
conn.execute("DELETE FROM memory_fts")
conn.commit()
except Exception as exc:
print_error(f"Failed to clear FTS5 index: {exc}")
sys.exit(1)
fts_indexed = 0
for mem in memories:
try:
asyncio.run(fts.index_memory(mem))
fts_indexed += 1
except Exception as exc:
print_warning(f"FTS5: failed to index {mem.id[:8]}: {exc}")
ok = False
# --- Vector: remove orphaned entries (requires sqlite-vec) ---
vector_db = db.parent / f"{db.stem}_vectors.db"
vector_msg = ""
if vector_db.exists():
# Orphan detection is based on every primary row, including
# superseded memories that are intentionally omitted from FTS.
with store._get_conn() as conn:
primary_ids = {row[0] for row in conn.execute("SELECT id FROM memories")}
try:
with sqlite3.connect(str(vector_db)) as conn:
if not _sqlite_table_exists(conn, "vec_metadata"):
vector_msg = ", vector index not initialized"
else:
import sqlite_vec
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
rows = conn.execute("SELECT memory_id FROM vec_metadata").fetchall()
orphan_ids = [r[0] for r in rows if r[0] not in primary_ids]
if orphan_ids:
for i in range(0, len(orphan_ids), 500):
chunk = orphan_ids[i : i + 500]
ph = ",".join("?" * len(chunk))
vec_rows = conn.execute(
f"SELECT rowid FROM vec_metadata WHERE memory_id IN ({ph})",
chunk,
).fetchall()
rowids = [r[0] for r in vec_rows]
if rowids:
rph = ",".join("?" * len(rowids))
conn.execute(
f"DELETE FROM vec_embeddings WHERE rowid IN ({rph})",
rowids,
)
conn.execute(
f"DELETE FROM vec_metadata WHERE rowid IN ({rph})",
rowids,
)
conn.commit()
vector_msg = (
f", removed {len(orphan_ids)} orphaned vector entry(ies)"
if orphan_ids
else ", vector index clean"
)
except ImportError:
vector_msg = (
" (vector index skipped: sqlite-vec not installed — "
"install with: pip install sqlite-vec)"
)
ok = False
except Exception as exc:
vector_msg = f" (vector index cleanup failed: {exc})"
ok = False
msg = f"Re-indexed {fts_indexed}/{len(memories)} memories{vector_msg}."
if ok:
print_success(msg)
else:
print_warning(msg)
sys.exit(1)
except Exception as e:
print_error(f"Failed to reindex: {e}")
sys.exit(1)
@memory.command("export")
@db_path_option
@click.option(

View file

@ -28,7 +28,10 @@ def output_savings() -> None:
if not path.exists():
click.echo("No output-savings data yet.")
click.echo("Run `headroom learn --verbosity --apply` to seed the baseline,")
click.echo("then enable the shaper (HEADROOM_OUTPUT_SHAPER=1) and send traffic.")
click.echo(
"then enable the beta shaper (HEADROOM_ROLLOUT_CHANNEL=beta "
"HEADROOM_OUTPUT_SHAPER=1) and send traffic."
)
return
ledger = SavingsLedger.load(path)

View file

@ -79,6 +79,8 @@ def perf(hours: float, raw: bool, output_format: str) -> None:
"tokens_before",
"tokens_after",
"tokens_saved",
"message_tokens_saved",
"tool_tokens_saved",
"savings_pct",
"list_price_per_mtok",
]

View file

@ -4,6 +4,7 @@ import logging
import os
import sys
import warnings
from importlib import import_module
from typing import Any, Literal, cast
import click
@ -18,6 +19,38 @@ from headroom.proxy.modes import PROXY_MODE_CACHE, normalize_proxy_mode
from .main import main
def ensure_proxy_dependencies() -> None:
"""Verify optional proxy extras are installed before starting or wrapping."""
required_modules: list[str] = [
"fastapi",
"uvicorn",
"httpx",
"openai",
"mcp",
"magika",
"zstandard",
"websockets",
"onnxruntime",
"transformers",
"watchdog",
]
if sys.implementation.name != "pypy":
required_modules.append("orjson")
try:
for module in required_modules:
import_module(module)
except ImportError as e:
click.secho(
"Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy]",
fg="red",
err=True,
)
click.secho(f"Details: {e}", fg="red", err=True)
raise SystemExit(1) from None
# ---------------------------------------------------------------------------
# Startup log suppression.
#
@ -258,7 +291,7 @@ def dashboard(port: int, no_open: bool) -> None:
is_flag=True,
help=(
"Opt in to tool_result interceptors (ast-grep Read outliner, etc.). "
"Off by default while this feature ships."
"Requires HEADROOM_ROLLOUT_CHANNEL=canary (or dev)."
),
)
@click.option("--no-optimize", is_flag=True, help="Disable optimization (passthrough mode)")
@ -313,6 +346,19 @@ def dashboard(port: int, no_open: bool) -> None:
"retrieval marker, so no MCP retrieve tool is needed. Env: HEADROOM_LOSSLESS=1."
),
)
@click.option(
"--ccr-inline-resolve",
is_flag=True,
envvar="HEADROOM_CCR_INLINE_RESOLVE",
help=(
"Resolve <<ccr:...>> markers inline on the response path instead of "
"relying on the model to call headroom_retrieve. For callers with no "
"tool-call round-trip to redeem a marker (e.g. Headroom running as a "
"LiteLLM guardrail/proxy hop, see issue #2509). Applies to non-streaming "
"responses only. Off by default. "
"Env: HEADROOM_CCR_INLINE_RESOLVE."
),
)
@click.option(
"--no-ccr-proactive-expansion",
is_flag=True,
@ -628,7 +674,8 @@ def dashboard(port: int, no_open: bool) -> None:
help=(
"EXPERIMENTAL: activity-based read maturation — hold fresh Reads "
"out of the provider prefix cache and compress them once their "
"file quiesces (env: HEADROOM_READ_MATURATION=1)"
"file quiesces. Requires HEADROOM_ROLLOUT_CHANNEL=beta (or dev); "
"env: HEADROOM_READ_MATURATION=1."
),
)
@click.option(
@ -934,6 +981,7 @@ def proxy(
tpm: int | None,
no_ccr: bool,
lossless: bool,
ccr_inline_resolve: bool,
no_ccr_proactive_expansion: bool,
proxy_extension: tuple[str, ...],
compressor: tuple[str, ...],
@ -1017,23 +1065,16 @@ def proxy(
Usage with OpenAI-compatible clients:
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
"""
ensure_proxy_dependencies()
# Import here to avoid slow startup
try:
from headroom.proxy.server import (
ProxyConfig,
_parse_csv_tools,
_parse_exclude_tools,
_parse_tool_profiles,
run_server,
)
except ImportError as e:
click.secho(
"Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy]",
fg="red",
err=True,
)
click.secho(f"Details: {e}", fg="red", err=True)
raise SystemExit(1) from None
from headroom.proxy.server import (
ProxyConfig,
_parse_csv_tools,
_parse_exclude_tools,
_parse_tool_profiles,
run_server,
)
# Warn if --learn and --no-learn are both set (--no-learn wins, per docstring)
if learn and no_learn:
@ -1066,12 +1107,46 @@ def proxy(
err=True,
)
# Resolve rollout inputs once before constructing any rollout-managed
# behavior. The immutable snapshot is injected into ProxyConfig and is also
# what /stats later exposes.
from headroom.rollout import resolve_rollout
rollout_requests = []
if intercept_tool_results:
rollout_requests.append("tool_result_interceptors")
if read_maturation:
rollout_requests.append("read_maturation")
rollout_snapshot = resolve_rollout(os.environ, requested=rollout_requests)
if read_maturation and not rollout_snapshot.is_enabled("read_maturation"):
click.secho(
"error: --read-maturation is not available in the current rollout channel "
f"({rollout_snapshot.channel.value}). Set HEADROOM_ROLLOUT_CHANNEL=beta "
"(or dev), or use HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1 for an "
"emergency override.",
fg="red",
err=True,
)
sys.exit(1)
# Opt-in: turn on tool_result interceptors (ast-grep Read outline, etc.).
# Only fetch the bundled CLI tool binaries when the feature is enabled —
# otherwise we'd pay a network round-trip and risk a readonly-FS failure
# for capabilities the user hasn't asked for. The TransformPipeline reads
# this env var at construction time.
# the resolved snapshot says it is active.
if intercept_tool_results:
if not rollout_snapshot.is_enabled("tool_result_interceptors"):
click.secho(
"error: --intercept-tool-results is not available in the current "
f"rollout channel ({rollout_snapshot.channel.value}). Set "
"HEADROOM_ROLLOUT_CHANNEL=canary to dogfood it, or use "
"HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1 for emergency override.",
fg="red",
err=True,
)
sys.exit(1)
from headroom.binaries import ensure_tools
resolved_tools = ensure_tools()
@ -1089,7 +1164,6 @@ def proxy(
err=True,
)
sys.exit(1)
os.environ["HEADROOM_INTERCEPT_ENABLED"] = "1"
try:
resolved_anthropic_extra_headers = resolve_extra_headers(
@ -1171,6 +1245,7 @@ def proxy(
config = ProxyConfig(
host=host,
port=port,
rollout=rollout_snapshot,
anthropic_api_url=provider_api_overrides.anthropic,
anthropic_extra_headers=resolved_anthropic_extra_headers,
openai_extra_headers=resolved_openai_extra_headers,
@ -1206,6 +1281,7 @@ def proxy(
# CCR fully on.
ccr_inject_tool=not no_ccr,
ccr_inject_marker=not no_ccr,
ccr_resolve_markers_inline=ccr_inline_resolve,
lossless=lossless,
ccr_proactive_expansion=not no_ccr_proactive_expansion,
# Flatten repeat-flag tuple AND any comma-separated values inside it.
@ -1276,7 +1352,7 @@ def proxy(
# Read lifecycle: ON by default (use --no-read-lifecycle to disable)
read_lifecycle=not no_read_lifecycle,
# Read maturation (Mechanism B): experimental, OFF by default
read_maturation=read_maturation,
read_maturation=rollout_snapshot.is_enabled("read_maturation"),
read_maturation_quiesce_turns=read_maturation_quiesce_turns,
read_maturation_max_hold_turns=read_maturation_max_hold_turns,
read_maturation_min_size_bytes=read_maturation_min_size_bytes,

66
headroom/cli/rollout.py Normal file
View file

@ -0,0 +1,66 @@
"""Runtime rollout diagnostics commands."""
from __future__ import annotations
import json
import os
import click
from headroom.rollout import RolloutConfigurationError, resolve_rollout
from .main import main
@main.group("rollout")
def rollout_group() -> None:
"""Inspect runtime feature-rollout policy (not package releases)."""
@rollout_group.command("status")
@click.option("--channel", envvar="HEADROOM_ROLLOUT_CHANNEL")
@click.option("--features", envvar="HEADROOM_FEATURES")
@click.option("--disable-features", envvar="HEADROOM_DISABLE_FEATURES")
@click.option(
"--unsafe-allow-unstable-features",
is_flag=True,
envvar="HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES",
)
@click.option("--json", "json_output", is_flag=True, help="Emit the versioned JSON snapshot.")
def rollout_status(
channel: str | None,
features: str | None,
disable_features: str | None,
unsafe_allow_unstable_features: bool,
json_output: bool,
) -> None:
"""Resolve and print the supplied runtime rollout configuration."""
env = dict(os.environ)
if channel is not None:
env["HEADROOM_ROLLOUT_CHANNEL"] = channel
if features is not None:
env["HEADROOM_FEATURES"] = features
if disable_features is not None:
env["HEADROOM_DISABLE_FEATURES"] = disable_features
if unsafe_allow_unstable_features:
env["HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES"] = "1"
try:
snapshot = resolve_rollout(env, strict=True)
except RolloutConfigurationError as exc:
raise click.ClickException(str(exc)) from exc
payload = snapshot.to_dict()
if json_output:
click.echo(json.dumps(payload, sort_keys=True, separators=(",", ":")))
return
click.echo(f"Rollout channel: {snapshot.channel.value}")
click.echo(f"Policy: {snapshot.policy_version} ({snapshot.registry_digest})")
click.echo(f"Snapshot: {snapshot.snapshot_digest}")
click.echo(f"Qualification eligible: {str(snapshot.qualification_eligible).lower()}")
for decision in snapshot.decisions:
click.echo(
f" {decision.name}: enabled={str(decision.enabled).lower()} "
f"decision={decision.reason.value}"
)

View file

@ -328,13 +328,14 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
1. git checkout refuse (`git pull`)
2. editable install refuse (reinstall from source)
3. Docker refuse (pull a new image)
3. explicit HEADROOM_IN_DOCKER refuse (official image opt-out)
4. pipx `pipx upgrade`
5. uv tool `uv tool upgrade`
6. venv / virtualenv / conda `sys.executable -m pip install -U`
7. user-site (`pip --user`) `sys.executable -m pip install -U --user`
8. externally-managed system Python (PEP 668) refuse with guidance
9. writable global Python `sys.executable -m pip install -U` (last resort)
8. bare /.dockerenv (system interpreter) refuse (pull a new image)
9. externally-managed system Python (PEP 668) refuse with guidance
10. writable global Python `sys.executable -m pip install -U` (last resort)
"""
if _is_source_checkout():
return InstallMethod(
@ -351,7 +352,13 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
"reinstall with `pip install -U --force-reinstall .`."
),
)
if _in_docker():
# An EXPLICIT HEADROOM_IN_DOCKER (set by the official image) is a deliberate
# "pull a newer image" opt-out and wins up front, even over a venv. The bare
# /.dockerenv heuristic is handled far lower, after ownership detection, so a
# pip / pipx / uv install inside a devcontainer, Codespace, or docker dev
# image is not shadowed by the mere fact that the environment is a container
# (#2816).
if os.environ.get("HEADROOM_IN_DOCKER", "").strip():
return InstallMethod(
kind="docker",
can_self_update=False,
@ -404,6 +411,18 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
argv=[sys.executable, "-m", "pip", "install", "-U", "--user", _spec(extras)],
)
# Bare /.dockerenv with no venv / pipx / uv / user-site owner: the install
# belongs to the container's own interpreter, where "pull a newer image" is
# the only real route. An explicit HEADROOM_IN_DOCKER already returned above.
if _in_docker():
return InstallMethod(
kind="docker",
can_self_update=False,
guidance=(
"Running inside a container — pull a newer Headroom image instead of self-updating."
),
)
if _is_externally_managed():
return InstallMethod(
kind="system",

View file

@ -33,6 +33,8 @@ import sys
import time
import urllib.parse
from collections.abc import Callable
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from typing import Any, cast
@ -57,6 +59,7 @@ from headroom._version import normalize_release_version as _normalize_release_ve
from headroom.agent_savings import (
apply_agent_savings_env_defaults,
)
from headroom.cli.proxy import ensure_proxy_dependencies
from headroom.copilot_auth import (
_API_TOKEN_ENV_VARS,
_API_TOKEN_EXPIRES_AT_ENV_VAR,
@ -74,6 +77,8 @@ from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
claude_auth_conflict_message,
claude_auth_conflict_sources,
claude_user_settings_path,
configure_vscode_claude_settings,
detect_claude_code_version,
@ -87,6 +92,7 @@ from headroom.providers.claude import (
from headroom.providers.claude import (
proxy_base_url as _claude_proxy_base_url,
)
from headroom.providers.claude.runtime import TOOL_SEARCH_FOUNDRY_DEFAULT
from headroom.providers.codex import build_launch_env as _build_codex_launch_env
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from headroom.providers.codex.threads import retag_to_headroom, retag_to_native
@ -256,6 +262,30 @@ def _read_settings_for_write(path: Path) -> dict[str, Any]:
return cast("dict[str, Any]", payload)
def _claude_settings_env(path: Path) -> dict[str, object]:
"""Read a Claude settings env block for preflight validation."""
env = _read_settings_for_write(path).get("env")
return dict(env) if isinstance(env, dict) else {}
def _raise_on_claude_auth_conflict(
*,
user_settings_path: Path,
project_settings_path: Path,
project_local_settings_path: Path,
environ: dict[str, str],
) -> None:
"""Refuse an auth state Claude Code rejects before mutating wrap state."""
conflict = claude_auth_conflict_sources(
(str(user_settings_path), _claude_settings_env(user_settings_path)),
(str(project_settings_path), _claude_settings_env(project_settings_path)),
(str(project_local_settings_path), _claude_settings_env(project_local_settings_path)),
("shell environment", environ),
)
if conflict is not None:
raise click.ClickException(claude_auth_conflict_message(conflict))
def _append_text(path: Path, content: str) -> None:
"""Append to a text file as UTF-8 without translating line endings."""
fsutil.append_text(path, content)
@ -269,13 +299,14 @@ _WRAP_PROXY_TIMEOUT_ML_MODULES = ("torch", "sentence_transformers", "spacy")
# Issue #746: Claude Code disables on-demand tool loading (deferral) when
# ANTHROPIC_BASE_URL is a custom host and ENABLE_TOOL_SEARCH is unset, which
# inflates the local context window by tens of K tokens. Setting the env var
# when we launch Claude Code keeps deferral on. Default to "true" — defer the
# MCP/system tools for maximum context savings, matching native first-party
# behaviour (core built-ins like Read/Edit/Bash are never deferred by Claude
# Code, so the agent loop is unaffected). The key/default are shared with
# `init` and `install` via the Claude provider package to prevent drift.
# when we launch Claude Code keeps deferral on. The generic default stays
# "true" for non-Foundry sessions, while Foundry uses a dedicated compatibility
# default of "false" because its upstream does not support the deferred-tool
# shape. The key/defaults are shared with `init` and `install` via the Claude
# provider package to prevent drift.
_TOOL_SEARCH_ENV = TOOL_SEARCH_ENV
_TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT
_TOOL_SEARCH_FOUNDRY_DEFAULT = TOOL_SEARCH_FOUNDRY_DEFAULT
_AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build"}
# 1M context window for `wrap claude` (#1158). Claude Code only sends the
@ -303,6 +334,33 @@ def _resolve_1m_model(current: str | None) -> str:
return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}"
def _apply_1m_to_claude_args(args: tuple[str, ...]) -> tuple[tuple[str, ...], str | None]:
"""Add the ``[1m]`` suffix to an explicit ``--model`` in pass-through args.
Claude Code gives the ``--model`` CLI flag precedence over the
``ANTHROPIC_MODEL`` env var, so when a user passes both ``--1m`` and
``--model X`` the env-var suffix is silently shadowed and the session caps at
200k (#2915). Rewriting the flag's value the same way ``_resolve_1m_model``
rewrites the env var keeps ``--1m`` effective on the higher-precedence flag.
Handles ``--model VALUE`` and ``--model=VALUE`` (the first occurrence only, as
Claude Code honours the first). Idempotent via ``_resolve_1m_model``. Returns
``(new_args, rewritten_value)``; ``rewritten_value`` is ``None`` when no
``--model`` was present (the env-var path already covers that case).
"""
out = list(args)
for i, arg in enumerate(out):
if arg == "--model" and i + 1 < len(out):
rewritten = _resolve_1m_model(out[i + 1])
out[i + 1] = rewritten
return tuple(out), rewritten
if arg.startswith("--model="):
rewritten = _resolve_1m_model(arg.split("=", 1)[1])
out[i] = f"--model={rewritten}"
return tuple(out), rewritten
return tuple(out), None
def _normalize_tool_search_mode(value: str) -> str:
"""Validate an ``ENABLE_TOOL_SEARCH`` value and return it normalized.
@ -331,7 +389,8 @@ def _configure_tool_search_env(env: dict[str, str], flag_value: str | None) -> s
1. explicit ``--tool-search`` flag wins (the user asked for it on the CLI),
2. a pre-existing ``ENABLE_TOOL_SEARCH`` in the environment respected and
left untouched (the user's own Claude Code knob),
3. the built-in default (``true``).
3. the built-in mode-specific default (``true`` normally, ``false`` on
Foundry).
Returns the value written, or ``None`` when an existing environment value
was deliberately left in place.
@ -346,8 +405,11 @@ def _configure_tool_search_env(env: dict[str, str], flag_value: str | None) -> s
existing = env.get(_TOOL_SEARCH_ENV)
if existing is not None and existing.strip():
return None
env[_TOOL_SEARCH_ENV] = _TOOL_SEARCH_DEFAULT
return _TOOL_SEARCH_DEFAULT
default = (
_TOOL_SEARCH_FOUNDRY_DEFAULT if env.get("CLAUDE_CODE_USE_FOUNDRY") else _TOOL_SEARCH_DEFAULT
)
env[_TOOL_SEARCH_ENV] = default
return default
# ENABLE_TOOL_SEARCH modes that turn deferral OFF. Everything else Claude Code
@ -415,6 +477,8 @@ def _resolved_tool_search_mode(flag_value: str | None) -> str:
existing = os.environ.get(_TOOL_SEARCH_ENV)
if existing is not None:
probe[_TOOL_SEARCH_ENV] = existing
if os.environ.get("CLAUDE_CODE_USE_FOUNDRY"):
probe["CLAUDE_CODE_USE_FOUNDRY"] = os.environ["CLAUDE_CODE_USE_FOUNDRY"]
written = _configure_tool_search_env(probe, flag_value)
return written if written is not None else probe.get(_TOOL_SEARCH_ENV, "")
@ -629,6 +693,15 @@ def _start_proxy(
proxy_env = os.environ.copy()
_scrub_copilot_proxy_seed_env(proxy_env)
proxy_env["PYTHONIOENCODING"] = "utf-8"
# `python -m headroom.cli` prepends the launch cwd to sys.path, so running
# `wrap` from a directory that contains a `headroom/` folder (most commonly a
# clone of this repo, whose package lives at <root>/headroom/) shadows the
# installed wheel with the raw source tree, which has no compiled
# `headroom._core`. The proxy then dies with "No module named 'headroom._core'"
# and wrap silently falls back to launching the client unwrapped (#2793).
# PYTHONSAFEPATH disables that cwd prepend (Python 3.11+; a harmless no-op on
# 3.10) so the subprocess always resolves the installed package.
proxy_env["PYTHONSAFEPATH"] = "1"
# Vertex AI RST_STREAMs HTTP/2 connections (error_code:2). Force HTTP/1.1
# when wrapping a Vertex-mode client so upstream requests succeed.
if os.environ.get("CLAUDE_CODE_USE_VERTEX") or os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID"):
@ -750,7 +823,7 @@ _RETIRED_CONTEXT_TOOL_MESSAGE = (
"rewrote shell commands through a third-party binary Headroom no longer "
"manages. Drop --context-tool / --no-context-tool and unset "
f"{_RETIRED_CONTEXT_TOOL_ENV}; `headroom wrap` uninstalls what they left "
"behind on first run."
"behind automatically."
)
@ -810,8 +883,10 @@ def _report_context_tool_purge() -> None:
default: the Claude ``PreToolUse`` hook, the vendored binaries and the
injected hint-file guidance are all durable on disk. Running this once per
``wrap`` / ``unwrap`` invocation is what actually makes the tools go away.
Silent when there is nothing to do, which is the steady state after the first
run, and never fatal a cleanup failure must not block launching the tool.
Silent when there is nothing to do the common case once the machine-global
half is stamped done, though the project- and config-directory-scoped half
still runs every launch and never fatal: a cleanup failure must not block
launching the tool.
Reports on **stderr**: some subcommands (``wrap/unwrap openclaw
--prepare-only``) emit machine-readable JSON on stdout as their entire
@ -1431,6 +1506,36 @@ def _write_claude_wrap_base_url(
return previous
def _write_claude_wrap_tool_search(value: str, *, settings_path: Path | None = None) -> str | None:
"""Persist the resolved tool-search mode for daemon-spawned workers.
Claude Code workers read project settings afresh rather than inheriting
the parent process environment (#2492). Keep this separate from the proxy
URL crash marker: a stale tool-search mode cannot route traffic to a dead
process, and is restored transactionally when the wrap session exits.
"""
path = settings_path or (Path.cwd() / ".claude" / "settings.local.json")
payload = _read_settings_for_write(path)
env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {}
previous = env_map.get(_TOOL_SEARCH_ENV)
env_map[_TOOL_SEARCH_ENV] = value
payload["env"] = env_map
path.parent.mkdir(parents=True, exist_ok=True)
_write_text(path, json.dumps(payload, indent=2) + "\n")
return previous
def _restore_claude_wrap_tool_search(
previous: str | None, *, settings_path: Path | None = None
) -> None:
"""Restore the project-local tool-search value written for this session."""
_restore_claude_wrap_base_url(
previous,
settings_path=settings_path,
_key_override=_TOOL_SEARCH_ENV,
)
def _restore_claude_wrap_base_url(
previous: str | None,
*,
@ -1671,6 +1776,18 @@ def _serena_project_skip_reason(root: Path) -> str | None:
Serena's own ``~/.serena`` config directory. A linked git worktree (its
top-level ``.git`` is a file, not a directory) is an ephemeral checkout that
would pay for its own index at a path that soon disappears.
A project with no ``.serena/project.yml`` is skipped because the pre-index
cannot succeed there (#2938). ``serena project index`` auto-creates the file
when it is missing, and that auto-creation calls
``ProjectConfig.autogenerate(interactive=True)``, which asks one ``[y/N]``
question per additionally-detected language server. The CLI has no
non-interactive switch; the only way to reach the silent branch is to pass
``--ls/--language`` explicitly, which means Headroom guessing the project's
languages again exactly the hand-maintained map removed below. Serena's
MCP server generates that file itself (non-interactively) on first start and
indexes lazily on demand, so the pre-index simply resumes from the next
wrap onwards.
"""
try:
resolved = root.resolve()
@ -1681,46 +1798,144 @@ def _serena_project_skip_reason(root: Path) -> str | None:
return "$HOME is not a project"
if (resolved / ".git").is_file():
return "linked git worktree"
if not (resolved / ".serena" / "project.yml").is_file():
return "no .serena/project.yml yet — Serena will create it and index on demand"
return None
#: Upper bound on the synchronous pre-index. The agent does not launch until
#: this call returns, so the number is a stall budget, not just a safety net.
_SERENA_INDEX_TIMEOUT = 300
def _kill_serena_index_tree(proc: subprocess.Popen) -> None:
"""Kill *proc* and everything it spawned (best-effort, never raises).
``uvx`` is a launcher: it resolves the environment and then runs the real
``serena`` executable as a grandchild. Killing only the direct child leaves
that grandchild alive and reparented to PID 1, so every timed-out pre-index
leaked one process that never exits (#2938 — the same failure mode as #615
and #880). The child is started in its own process group precisely so the
whole tree can be signalled here.
"""
if sys.platform == "win32":
# Windows has no process groups to signal for an already-wedged child;
# ``taskkill /T`` walks the tree by parent PID instead. ``/F`` because a
# process blocked in a read will not act on a graceful close request.
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True,
timeout=10,
check=False,
)
except Exception:
pass
else:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
# Backstop: if the tree kill above did not land, at least the direct child
# goes. Then reap so the parent does not leave a zombie behind, and close
# the capture pipes we opened so the wrap does not carry stray fds into the
# agent it is about to exec.
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=10)
except Exception:
pass
for stream in (proc.stdout, proc.stderr, proc.stdin):
try:
if stream is not None:
stream.close()
except Exception:
pass
def _index_serena_project(*, verbose: bool = False) -> None:
"""Warm Serena's symbol cache for the current project (non-fatal).
Runs ``serena project index`` (the same ``uvx --from git+`` launch used to
start the MCP server) in the project directory so the first symbol query is
not paying for a cold index. Timeout-guarded and best-effort: Serena also
indexes lazily on demand, so a failure or timeout here never blocks the
wrap.
Runs ``serena project index`` (the same ``uvx --from serena-agent`` launch
used to start the MCP server) in the project directory so the first symbol
query is not paying for a cold index. Serena also indexes lazily on demand,
so any failure here is survivable.
This runs on the launch path, synchronously: the agent starts only once it
returns, so the timeout below is time the user spends staring at nothing.
Two guards keep that bounded (#2938):
* ``stdin`` is ``DEVNULL``. Serena prompts when it has to auto-create
``project.yml``, and because stdout is captured the question never
reaches the terminal an inherited stdin turned that into a silent,
full-timeout hang. EOF makes it fail in about a second instead.
``_serena_project_skip_reason`` already keeps us out of that state; this
is the belt-and-braces half, and it covers any future Serena prompt too.
* The child gets its own process group so ``_kill_serena_index_tree`` can
take out the ``uvx`` grandchild on timeout rather than orphaning it.
"""
if shutil.which("uvx") is None:
if verbose:
click.echo(" Serena: uvx not found — skipping pre-index")
return
popen_kwargs: dict[str, Any] = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"stdin": subprocess.DEVNULL,
"text": True,
# ``subprocess.Popen`` directly, so the encoding defaults that
# ``headroom._subprocess.run`` applies have to be repeated here.
"encoding": "utf-8",
"errors": "replace",
"cwd": str(Path.cwd()),
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
try:
result = run(
proc = subprocess.Popen(
[
"uvx",
# PyPI (prebuilt wheels), not the git source that fails to build
# under proot-based filesystems (#2871).
"--from",
"git+https://github.com/oraios/serena",
"serena-agent",
"serena",
"project",
"index",
],
capture_output=True,
text=True,
timeout=300,
cwd=str(Path.cwd()),
**popen_kwargs,
)
if result.returncode == 0:
click.echo(" Serena: project pre-indexed (symbol cache warmed)")
elif verbose:
click.echo(f" Serena: pre-index failed ({(result.stderr or '')[:100]})")
except subprocess.TimeoutExpired:
click.echo(" Serena: pre-index timed out (will index on demand)")
except Exception as e:
if verbose:
click.echo(f" Serena: pre-index skipped ({e})")
return
# Announce the wait. Indexing a large repo legitimately takes minutes and
# the output is captured, so without this line the wrap looks hung.
click.echo(" Serena: pre-indexing project (first run can take a while)…")
try:
_stdout, stderr = proc.communicate(timeout=_SERENA_INDEX_TIMEOUT)
except subprocess.TimeoutExpired:
_kill_serena_index_tree(proc)
click.echo(" Serena: pre-index timed out (will index on demand)")
return
except Exception as e:
_kill_serena_index_tree(proc)
if verbose:
click.echo(f" Serena: pre-index skipped ({e})")
return
if proc.returncode == 0:
click.echo(" Serena: project pre-indexed (symbol cache warmed)")
elif verbose:
click.echo(f" Serena: pre-index failed ({(stderr or '')[:100]})")
def _setup_serena_mcp(
@ -1787,7 +2002,9 @@ def _setup_serena_mcp(
# Serena is the active engine here (we passed the detect/uvx guards): steer
# the agent toward symbol-level tools, then warm the symbol cache. Both are
# best-effort and non-fatal — neither blocks the wrap.
# best-effort and non-fatal, but the pre-index is *synchronous* — the agent
# does not launch until it returns or hits ``_SERENA_INDEX_TIMEOUT``. See
# ``_index_serena_project`` for how that wait is kept bounded and visible.
#
# Headroom no longer writes ``.serena/project.yml`` language scoping. Serena
# determines the project's languages itself during
@ -3416,7 +3633,7 @@ def _push_runtime_env(port: int, no_proxy: bool) -> None:
click.echo(f" Synced output settings to proxy: {', '.join(sorted(payload))}")
def _ensure_proxy(
def _ensure_proxy_unlocked(
port: int,
no_proxy: bool,
*,
@ -3435,7 +3652,13 @@ def _ensure_proxy(
copilot_refresh_oauth_token: str | None = None,
copilot_api_token_expires_at: float | None = None,
) -> tuple[subprocess.Popen | None, int]:
"""Start or verify proxy. Returns (process_handle, actual_port)."""
"""Start or verify proxy. Returns (process_handle, actual_port).
The public ``_ensure_proxy`` wrapper serializes callers per port before
entering this function. Keeping the implementation separate makes the
lock boundary explicit and ensures every health/configuration check runs
under the same startup critical section.
"""
helpers = _live_wrap_module()
copilot_subscription_seed_requested = (
bool(copilot_api_token)
@ -3802,6 +4025,81 @@ def _ensure_proxy(
return None, port
@contextmanager
def _proxy_start_lock(port: int) -> Any:
"""Serialize wrap proxy startup across processes sharing a port.
A proxy can spend tens of seconds loading optional ML components before it
binds its socket. Without this lock, two concurrent ``headroom wrap``
commands both see an unavailable health endpoint, choose the same port,
and race to spawn a listener. The lock is deliberately held through the
health/configuration checks and startup, then released once the proxy is
ready (or startup fails). Lock files are retained so an interrupted
process cannot create an inode-replacement race for another waiter.
"""
from headroom import paths as _paths
lock_path = _paths.proxy_start_lock_path(port)
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_file = open(lock_path, "a+b") # noqa: SIM115
except OSError:
# Locking is a race-prevention enhancement, not a reason to make wrap
# unusable when a read-only/custom workspace cannot hold state. The
# existing port bind remains the final safety check in that degraded
# environment.
yield
return
with lock_file:
if sys.platform == "win32":
import msvcrt
# msvcrt.locking operates on bytes from the current file position.
lock_file.seek(0)
if lock_file.read(1) == b"":
lock_file.seek(0)
lock_file.write(b"0")
lock_file.flush()
lock_file.seek(0)
# LK_LOCK has implementation-dependent retry limits. A proxy may
# legitimately take longer than that to load ML components, so
# use the non-blocking primitive in a loop instead.
while True:
try:
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
break
except OSError:
time.sleep(0.05)
try:
yield
finally:
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@wraps(_ensure_proxy_unlocked)
def _ensure_proxy(
port: int,
no_proxy: bool,
**kwargs: Any,
) -> tuple[subprocess.Popen | None, int]:
"""Start or reuse a proxy without racing another wrap on the same port."""
if no_proxy:
return _ensure_proxy_unlocked(port, no_proxy, **kwargs)
with _proxy_start_lock(port):
# Re-checking is part of the lock boundary: a concurrent wrapper may
# have finished startup while this caller was waiting for the lock.
return _ensure_proxy_unlocked(port, no_proxy, **kwargs)
def _client_marker_path(port: int) -> Path:
"""Path to this process's wrap-client marker for ``port``."""
from headroom import paths as _paths
@ -4463,6 +4761,8 @@ def claude(
proxy_holder: list[subprocess.Popen | None] = [None]
_saved_base_url: list[str | None] = [None] # previous settings.json value for restore
_tool_search_not_written = object()
_saved_tool_search: list[object | str | None] = [_tool_search_not_written]
_settings_foundry: list[bool] = [False]
port_holder: list[int] = [port]
_settings_vertex: list[bool] = [False]
@ -4471,6 +4771,12 @@ def claude(
# early proxy-start failure would make the finally raise UnboundLocalError,
# masking the real error and skipping cleanup(). Mirrors the holders above.
_wrap_settings_path = Path.cwd() / ".claude" / "settings.local.json"
_raise_on_claude_auth_conflict(
user_settings_path=claude_user_settings_path(),
project_settings_path=Path.cwd() / ".claude" / "settings.json",
project_local_settings_path=_wrap_settings_path,
environ=dict(os.environ),
)
cleanup = _make_cleanup(proxy_holder, port_holder)
signal.signal(signal.SIGINT, _ignore_child_sigint)
signal.signal(signal.SIGTERM, cleanup)
@ -4694,6 +5000,11 @@ def claude(
# Issue #746: keep Claude Code's on-demand tool loading on through the
# proxy so tool schemas are not eagerly materialized into local context.
_tool_search_value = _configure_tool_search_env(env, tool_search)
_resolved_tool_search_value = env.get(_TOOL_SEARCH_ENV, "")
_saved_tool_search[0] = _write_claude_wrap_tool_search(
_resolved_tool_search_value,
settings_path=_wrap_settings_path,
)
if _tool_search_value is not None:
# Describe what the written value actually does: --tool-search
# false/0/no/off turns deferral OFF, and the banner must say so
@ -4717,10 +5028,18 @@ def claude(
# force it via ANTHROPIC_MODEL on the launched process.
if context_1m:
env[_ANTHROPIC_MODEL_ENV] = _resolve_1m_model(env.get(_ANTHROPIC_MODEL_ENV))
click.echo(
f" {_ANTHROPIC_MODEL_ENV}={env[_ANTHROPIC_MODEL_ENV]} "
"(1M context window; issue #1158)"
)
# An explicit pass-through --model outranks ANTHROPIC_MODEL in Claude
# Code, so add the suffix there too or the env var is silently
# shadowed and the window stays 200k (#2915). Report what will
# actually take effect rather than the shadowed env value.
claude_args, _model_flag_1m = _apply_1m_to_claude_args(claude_args)
if _model_flag_1m is not None:
click.echo(f" --model {_model_flag_1m} (1M context window; issue #1158)")
else:
click.echo(
f" {_ANTHROPIC_MODEL_ENV}={env[_ANTHROPIC_MODEL_ENV]} "
"(1M context window; issue #1158)"
)
result = subprocess.run([claude_bin, *claude_args], env=env)
raise SystemExit(result.returncode)
@ -4731,6 +5050,11 @@ def claude(
click.echo(f" Error: {e}")
raise SystemExit(1) from e
finally:
if _saved_tool_search[0] is not _tool_search_not_written:
_restore_claude_wrap_tool_search(
cast(str | None, _saved_tool_search[0]),
settings_path=_wrap_settings_path,
)
_restore_claude_wrap_base_url(
_saved_base_url[0],
foundry_mode=_settings_foundry[0],
@ -5044,8 +5368,11 @@ def copilot(
"automatic model selection."
)
env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API")
effective_wire_api = wire_api or (
_copilot_default_wire_api_for_model(selected_model) if subscription else "completions"
env_wire_api
if env_wire_api in {"completions", "responses"}
else _copilot_default_wire_api_for_model(selected_model)
)
env["COPILOT_PROVIDER_TYPE"] = "openai"
# Per-project savings: the Copilot CLI cannot send custom headers, so
@ -5184,8 +5511,8 @@ def vscode_copilot(
) -> None:
"""Run Headroom for GitHub Copilot inside Visual Studio Code.
Transparently overrides Copilot's proxy endpoint, preserving the model
selected in VS Code. It does not edit Codex settings.
Transparently overrides Copilot's proxy and CAPI endpoints, preserving the
model selected in VS Code. It does not edit Codex settings.
"""
resolution = _require_copilot_subscription_resolution()
target_settings = settings_file or vscode_settings_path()
@ -5205,6 +5532,9 @@ def vscode_copilot(
click.echo(
f' "github.copilot.advanced.debug.overrideProxyUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
)
click.echo(
f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
)
click.echo(' "github.copilot.advanced.debug.overrideAuthType": "token"')
_run_proxy_only_watcher(
@ -5469,6 +5799,9 @@ def _run_codex_wrap(
codex_args: tuple,
) -> None:
"""Execute the Codex wrap flow against the durable Codex home."""
if not no_proxy:
ensure_proxy_dependencies()
if prepare_only:
_prepare_codex_wrap_state(
port=port,
@ -6936,6 +7269,20 @@ def opencode(
)
subscription_resolution = _require_copilot_subscription_resolution()
# Verify the opencode binary exists BEFORE mutating any config. Otherwise a
# missing binary leaves headroom MCP/Serena/memory entries in the user's
# opencode config and an injected AGENTS.md, then errors with no cleanup --
# the config-before-verify anti-pattern (#1614). Siblings (claude, codex,
# goose, omp) already check first. `--prepare-only` intentionally writes
# config without launching, so it is exempt.
opencode_bin: str | None = None
if not prepare_only:
opencode_bin = shutil.which("opencode")
if not opencode_bin:
click.echo("Error: 'opencode' not found in PATH.")
click.echo("Install OpenCode: https://opencode.ai")
raise SystemExit(1)
# Snapshot OpenCode config.json BEFORE any wrap-time mutation so
# `headroom unwrap opencode` can restore the user's pre-wrap state.
_opencode_config_file, _opencode_backup_file = opencode_config_paths()
@ -6976,11 +7323,9 @@ def opencode(
inject_opencode_provider_config(port)
return
opencode_bin = shutil.which("opencode")
if not opencode_bin:
click.echo("Error: 'opencode' not found in PATH.")
click.echo("Install OpenCode: https://opencode.ai")
raise SystemExit(1)
# Past the prepare-only return the launch path always ran the binary check
# above, so opencode_bin is resolved.
assert opencode_bin is not None
# Register our proxy client marker BEFORE _ensure_proxy so that another
# wrapper's cleanup sees us as an active client and doesn't terminate a

View file

@ -11,6 +11,7 @@ from enum import Enum
from typing import Any, Literal
from headroom.models.config import ML_MODEL_DEFAULTS
from headroom.rollout import RolloutSnapshot, resolve_rollout
class HeadroomMode(str, Enum):
@ -672,9 +673,14 @@ class HeadroomConfig:
content_router_enabled: InitVar[bool | None] = None
# Tool-result interceptors (ast-grep Read outline, etc.). Opt-in for now.
# Env var HEADROOM_INTERCEPT_ENABLED=1 also enables (for CLI `--intercept-tool-results`).
# The legacy env alias and this typed request still obey the canary rollout gate.
intercept_tool_results: bool = False
# Immutable runtime rollout state. ``None`` is resolved once here so every
# pipeline built from this config observes the same decisions even if the
# process environment later changes.
rollout: RolloutSnapshot | None = None
# Debugging - opt-in diff artifact generation
generate_diff_artifact: bool = False # Enable to get detailed transform diffs
@ -682,6 +688,11 @@ class HeadroomConfig:
pipeline_extensions: list[Any] = field(default_factory=list)
discover_pipeline_extensions: bool = True
def __post_init__(self, content_router_enabled: bool | None = None) -> None:
if self.rollout is None:
requested = ("tool_result_interceptors",) if self.intercept_tool_results else ()
self.rollout = resolve_rollout(requested=requested)
def get_context_limit(self, model: str) -> int | None:
"""
Get context limit for a model from user overrides.

View file

@ -10,26 +10,87 @@ Deleting the code is not enough: everything above is *durable state on the
user's disk*. Left alone, the Claude hooks keep rewriting every Bash command
through binaries Headroom no longer manages, and the injected guidance keeps
telling agents to use tools that may not resolve. So ``headroom wrap`` /
``headroom unwrap`` call :func:`purge_context_tool_artifacts` once per run to
remove what earlier versions installed.
``headroom unwrap`` call :func:`purge_context_tool_artifacts` on every run to
remove what earlier versions installed machine-global artifacts (hooks,
binaries, Claude Code's MCP registration) are removed once per workspace and
then stamped done (see the stamp below), while project- and config-directory-
scoped guidance (``CODEX_HOME`` / ``OPENCODE_HOME`` hint files, Continue's
config) is inspected on every launch, since a later launch can sit in a
different project or point at a different ``CODEX_HOME`` / ``OPENCODE_HOME``.
Everything here is idempotent, best-effort and deliberately conservative:
* only files Headroom installed (or caused a context tool to install) are
deleted;
deleted. An MCP entry's ``command`` or a hook script's body counts as
Headroom's only when it names a path inside :func:`paths.bin_dir`
(:func:`_references_managed_bin` which is where the matching rules and
the reasons behind them live);
* a hook entry is Headroom's when it names such a path directly, or — the
common case, since a hook command names a script rather than the binary
when it names one of the ``~/.claude/hooks`` scripts already classified as
Headroom's, whose verdict it inherits
(:func:`_references_context_tool`, :func:`_names_a_managed_script`). A
Cursor ``hooks.json`` entry naming a script under ``~/.cursor`` cannot
inherit a verdict this way, since the map covers ``~/.claude/hooks`` only;
it is still caught when its ``command`` names the managed directory;
* ``.rtk-hook.sha256`` is never read for its own provenance (it holds a hex
digest, not a path) and instead inherits ``rtk-rewrite.sh``'s
classification; a ``<name>.lean-ctx.bak`` backup inherits ``<name>``'s
(:func:`_classify_hook_scripts`);
* a hook script that exists but cannot be read is classified unknown
deleted by nothing, and named in the report so the user can remove it by
hand;
* ``~/.local/bin/{rtk,lean-ctx}`` is unlinked only when it is a symlink into
Headroom's own bin directory — a user's own build is never touched;
* a JSON config that does not parse is reported and **skipped**, never
overwritten (a hand-edited typo must not cost the user their settings);
* the tools' own backups of *config* files (``~/.claude.json.lean-ctx.bak`` and
friends) are left in place they hold the user's real settings history. Only
backups of the hook scripts being deleted are cleaned up.
backups of the hook scripts proven to be Headroom's are cleaned up;
* two cases cannot be decided at all, and are accepted as limits rather than
fixed:
* ``get_lean_ctx_path`` used to check ``PATH`` before Headroom's own bin
directory, so on a machine that already had ``lean-ctx`` on ``PATH``,
the tool that ran was the user's own, and the config it wrote looks
exactly like config the user wrote by hand. That leftover survives the
purge it still points at a binary that exists, so nothing dangles;
* an rtk hook written *after* #1698 execs a bare ``rtk`` and never mentions
:func:`paths.bin_dir`, so it reads exactly like a hook a user wrote by
hand, and ``rtk-rewrite.sh``, its ``.rtk-hook.sha256`` and its
``settings.json`` entry all survive while step 3 removes the managed
binary leaving a hook that silently no-ops (#487, #1698). Earlier
hooks are decidable: Headroom patched the absolute managed path into
them (``_patch_rtk_hook_absolute_path``, removed by #1698), so the
window this misses is rtk setups run between #1698 and the tools'
removal in #2677;
* the marker-fenced guidance block is the one step with no provenance check
to make ``<!-- headroom:rtk-instructions -->`` is Headroom's own fence,
and no third party writes it.
Removing the retired integration's machine-global footprint — hook
registrations, hook scripts, PATH symlinks, managed binaries and Claude
Code's own MCP registration — is a one-time migration: the first completed
run of that half stamps ``.context-tools-purged`` beside the managed bin
directory, and every later run skips that half outright. Without the stamp
this would keep rewriting the same machine-wide files on every ``wrap``
invocation forever, and a user who installs one of these tools *after* the
migration would have Headroom auditing files at each launch for a leftover
that cannot exist there.
Project- and config-directory-scoped state is not covered by that stamp: a
later invocation can sit in a different project, or point ``CODEX_HOME`` /
``OPENCODE_HOME`` somewhere the stamped run never inspected, and whatever
guidance an earlier Headroom left behind there is still worth removing so
those steps run on every invocation instead (:func:`_purge_invocation_scoped`).
"""
from __future__ import annotations
import json
import os
import posixpath
import re
from pathlib import Path
from typing import Any
@ -69,52 +130,138 @@ _HOOK_SCRIPTS = (
"lean-ctx-redirect-native",
)
# rtk's integrity digest never names a path (see ``_classify_hook_scripts``)
# and rtk-rewrite.sh is the script it authenticates.
_RTK_DIGEST_NAME = ".rtk-hook.sha256"
_RTK_SCRIPT_NAME = "rtk-rewrite.sh"
# MCP server entries the tools registered, and the config files holding them.
# lean-ctx registers itself as an MCP server during ``lean-ctx init``; rtk never
# did, but it is matched too so a stale hand-added entry is cleaned up as well.
_MCP_SERVER_NAMES = ("lean-ctx", "lean_ctx", "rtk")
# Report-line prefixes meaning "this one is not settled" — a config that would
# not parse, a script that would not read, a file that would not unlink. Such a
# run leaves a leftover behind, so it must not be stamped as the completed
# migration. Emitted by _purge_hook_config, _purge_mcp_entries,
# _purge_fenced_block, _purge_continue_system_messages and _remove_files.
_DEFERRED_PREFIXES = ("skipped ", "could not remove ")
def purge_context_tool_artifacts() -> list[str]:
"""Remove every rtk / lean-ctx artifact an earlier Headroom version installed.
Returns human-readable descriptions of what was removed plus a line for
any config that had to be skipped because the user must fix it by hand. An
empty list means there was nothing to do, which is the steady state after
the first run.
any config that had to be skipped because the user must fix it by hand.
Machine-global cleanup (hooks, binaries, Claude Code's MCP registration)
runs once and is then skipped via the stamp below; project- and config-
directory-scoped cleanup (hint files, ``CODEX_HOME`` / ``OPENCODE_HOME``,
Continue's config) runs on every call, so a later call in a different
project or a repointed ``CODEX_HOME`` / ``OPENCODE_HOME`` can still report
something even after the global half is long since stamped done.
"""
marker = _purge_marker()
home = Path.home()
project = Path.cwd()
report: list[str] = []
# 1. Hook registrations (Claude Code's settings.json, Cursor's hooks.json).
for config in (home / ".claude" / "settings.json", home / ".cursor" / "hooks.json"):
report += _purge_hook_config(config)
if not marker.exists():
# Classify every hook script's provenance once, up front: both step 1
# (is a settings.json entry pointing at *our* script?) and step 2 (is
# the script itself ours?) need the same answer, and each file is
# read once.
hooks_dir = home / ".claude" / "hooks"
hook_classification = _classify_hook_scripts(hooks_dir)
# 2. The generated hook scripts, their integrity digests and stale backups.
hooks_dir = home / ".claude" / "hooks"
report += _remove_files(
*(hooks_dir / name for name in _HOOK_SCRIPTS),
*(hooks_dir / f"{name}.lean-ctx.bak" for name in _HOOK_SCRIPTS),
)
# 1. Hook registrations (Claude Code's settings.json, Cursor's hooks.json).
for config in (home / ".claude" / "settings.json", home / ".cursor" / "hooks.json"):
report += _purge_hook_config(config, hooks_dir, hook_classification)
# 3. The PATH symlinks, then the managed binaries they pointed at.
for name in ("rtk", "lean-ctx"):
report += _remove_managed_path_link(home / ".local" / "bin" / name)
report += _remove_files(*(paths.bin_dir() / name for name in _BINARY_NAMES))
# 2. The generated hook scripts, their integrity digests and stale
# backups — only the ones proven to reference Headroom's managed bin
# directory.
managed_names = [name for name in _HOOK_SCRIPTS if hook_classification.get(name)]
report += _remove_files(
*(hooks_dir / name for name in managed_names),
*(hooks_dir / f"{name}.lean-ctx.bak" for name in managed_names),
)
for name in _HOOK_SCRIPTS:
if hook_classification.get(name, False) is not None:
continue
if name == _RTK_DIGEST_NAME:
report.append(
f"skipped {hooks_dir / name} (inherits {_RTK_SCRIPT_NAME}'s unreadable verdict)"
" — remove any stale hook script by hand"
)
else:
report.append(
f"skipped {hooks_dir / name} (could not read to verify it was Headroom's)"
" — remove any stale hook script by hand"
)
# 4. MCP server registrations (lean-ctx registers itself during init).
report += _purge_mcp_entries(home / ".claude.json", "mcpServers")
# 3. The PATH symlinks, then the managed binaries they pointed at.
for name in ("rtk", "lean-ctx"):
report += _remove_managed_path_link(home / ".local" / "bin" / name)
report += _remove_files(*(paths.bin_dir() / name for name in _BINARY_NAMES))
# 4. Claude Code's own MCP server registration (lean-ctx registers
# itself during init). OpenCode's is invocation-scoped — see below.
report += _purge_mcp_entries(home / ".claude.json", "mcpServers")
# Only a global half that settled everything is the completed
# migration. One that could not read a script or parse a config left
# a leftover behind, and the user needs both the reminder on the next
# launch and the cleanup once the permissions or the typo are fixed.
# A deferral in the invocation-scoped half below must not withhold
# this stamp — that half re-runs every time regardless, so nothing is
# lost by stamping the global half done now.
if not any(line.startswith(_DEFERRED_PREFIXES) for line in report):
try:
# Cleanup must not become the first mutation on a pristine
# machine. In particular, ``wrap <missing-tool>`` validates
# the binary after the wrap-group migration hook; creating
# ``~/.headroom`` merely to stamp an empty scan violates that
# command's no-side-effects-on-failure contract. Established
# Headroom installs already have the state directory and get
# the one-time fast path; clean machines cheaply rescan until
# some real Headroom state exists.
if marker.parent.is_dir():
marker.touch()
except OSError:
pass # Unwritable workspace: the purge simply runs again next time.
report += _purge_invocation_scoped(home, project)
return report
def _purge_invocation_scoped(home: Path, project: Path) -> list[str]:
"""Steps the one-time stamp must never withhold.
``OPENCODE_HOME``'s config, the hint files in ``project`` /
``CODEX_HOME`` / ``OPENCODE_HOME``, and Continue's config are all a
function of *this* invocation's cwd and environment, not of the machine —
a later run can sit in a different project or point ``CODEX_HOME`` /
``OPENCODE_HOME`` somewhere the global-half stamp never inspected. Each
step is cheap and side-effect-free when nothing matches, so re-running
them on every invocation costs a handful of reads in the steady state.
"""
report: list[str] = []
report += _purge_mcp_entries(_opencode_home(home) / "opencode.json", "mcp")
# 5. Marker-fenced guidance in every hint file the wrap harnesses wrote to.
for hint_file in _instruction_files(home, project):
report += _purge_fenced_block(hint_file)
report += _purge_continue_system_messages(project / ".continue" / "config.json")
return report
def _purge_marker() -> Path:
"""Path of the "already migrated" stamp.
Derived from :func:`paths.bin_dir` rather than ``workspace_dir`` so it
cannot escape a temporary tree through ``HEADROOM_WORKSPACE_DIR``.
"""
return paths.bin_dir().parent / ".context-tools-purged"
def _instruction_files(home: Path, project: Path) -> list[Path]:
"""Hint files the wrap subcommands injected the context-tool block into.
@ -151,15 +298,194 @@ def _opencode_home(home: Path) -> Path:
# --- hook registrations -------------------------------------------------------
def _references_context_tool(entry: Any) -> bool:
"""Whether a hook entry's command is one a retired context tool registered."""
# Characters that can never be part of a path: a match ending right before
# one of these (or at end-of-string) sits at a real word boundary. Quotes,
# `=`/`:` (`export PATH="<dir>:$PATH"`, `BIN=<dir>/x`), `;`/`,`/`|` (command
# joiners) and `()` (subshells) all end a path the same way whitespace does.
_PATH_BOUNDARY_CHARS = frozenset("\"'=:;,()|")
# Splits a command into path-shaped tokens on whitespace plus the same
# boundary punctuation above — used by _names_a_managed_script, which (unlike
# _references_managed_bin's needle-anchored scan) tokenizes the whole command.
_PATH_TOKEN_SPLIT = re.compile(r"[\s" + re.escape("".join(_PATH_BOUNDARY_CHARS)) + r"]+")
def _norm_path_text(value: str) -> str:
"""Case-fold ``value`` and give it one separator, so paths compare as text."""
return os.path.normcase(value).replace("\\", "/")
def _references_managed_bin(text: str) -> bool:
"""Whether ``text`` names a path inside Headroom's managed bin directory.
A hook command or script body is free text we don't control, so this
scans ``text`` for raw occurrences of the managed directory the
unresolved and resolved bin directory, and its ``~``-relative form
(home-relative, since a script may reference it unexpanded) matched
against the text as given and as ``expanduser``'d, case-folded, with both
path separators. Deliberately *not* tokenized on whitespace first: a
quoted or ``$HOME``-derived path can itself contain a space, and slicing
the text into words before searching would sever it.
A hit is only a real reference at a path boundary on *both* ends. The
character immediately before the match, if any, must be whitespace or a
:data:`_PATH_BOUNDARY_CHARS` character, or the match is just the tail of
some longer, unrelated path segment (e.g. ``/prefix<bin_dir>/lean-ctx``)
and is rejected. The match must then be immediately followed by
end-of-string or a :data:`_PATH_BOUNDARY_CHARS` character (an exact
reference, e.g. a bare ``PATH=<dir>`` export), or by ``/`` in which
case the run of characters up to the next boundary is lexically
normalized (``.``/``..`` collapsed) and re-compared, so neither a sibling
directory like ``bin-backup``/``binfoo`` nor a ``bin/../evil`` traversal
can borrow the managed prefix.
# ponytail: boundary-aware substring scan, not a shell parse — upgrade to
# shlex if a command ever embeds a managed path it does not execute.
"""
try:
bin_dir = paths.bin_dir()
resolved_bin_dir = bin_dir.resolve()
except OSError:
return False
needles = {_norm_path_text(str(bin_dir)), _norm_path_text(str(resolved_bin_dir))}
home = Path.home()
for base in (bin_dir, resolved_bin_dir):
try:
needles.add(_norm_path_text(f"~/{base.relative_to(home).as_posix()}"))
except ValueError:
pass
for haystack in (text, os.path.expanduser(text)):
normalized_haystack = _norm_path_text(haystack)
if any(_names_managed_dir(normalized_haystack, needle) for needle in needles):
return True
return False
def _names_managed_dir(haystack: str, needle: str) -> bool:
"""Whether a normalized ``haystack`` names ``needle`` at a path boundary
on both ends: the character right before the match, if any, must be
whitespace or a :data:`_PATH_BOUNDARY_CHARS` character too, or a
user-owned path that merely has the managed directory as a substring
(e.g. ``/prefix<bin_dir>/lean-ctx``) would be misread as naming it.
"""
search_from = 0
while True:
index = haystack.find(needle, search_from)
if index < 0:
return False
end = index + len(needle)
search_from = index + 1 # keep scanning; occurrences may overlap
if index > 0 and not (
haystack[index - 1].isspace() or haystack[index - 1] in _PATH_BOUNDARY_CHARS
):
continue
following = haystack[end : end + 1]
if not following or following.isspace() or following in _PATH_BOUNDARY_CHARS:
return True
if following != "/":
continue
tail_end = end
while tail_end < len(haystack) and not (
haystack[tail_end].isspace() or haystack[tail_end] in _PATH_BOUNDARY_CHARS
):
tail_end += 1
candidate = posixpath.normpath(haystack[index:tail_end])
if candidate == needle or candidate.startswith(needle + "/"):
return True
def _classify_hook_scripts(hooks_dir: Path) -> dict[str, bool | None]:
"""Classify each existing ``_HOOK_SCRIPTS`` file by whether it is Headroom's.
``True`` the file exists and its body references the managed bin
directory. ``False`` it exists and does not. ``None`` it exists but
could not be read, so its provenance is unprovable. A basename with no
file on disk is simply absent from the map. Each file is read at most once.
``.rtk-hook.sha256`` holds a hex digest that can never reference a path,
so it is never read; it inherits ``rtk-rewrite.sh``'s classification.
"""
classification: dict[str, bool | None] = {}
for name in _HOOK_SCRIPTS:
if name == _RTK_DIGEST_NAME:
continue
path = hooks_dir / name
if not path.is_file():
continue
try:
body = fsutil.read_text(path)
except OSError:
classification[name] = None
continue
classification[name] = _references_managed_bin(body)
if (hooks_dir / _RTK_DIGEST_NAME).is_file():
classification[_RTK_DIGEST_NAME] = classification.get(_RTK_SCRIPT_NAME, False)
return classification
def _references_context_tool(
entry: Any, hooks_dir: Path, hook_classification: dict[str, bool | None]
) -> bool:
"""Whether a hook entry is one a retired context tool registered.
A command-marker hit alone is not enough a user could author a script
with a matching name. It must also either name the managed bin directory
directly, or name as a path resolving to that exact file, not merely
sharing its basename, so a same-named script of the user's own in a
different directory is never caught a hook script in ``hooks_dir`` the
classification map marks ``True`` (see :func:`_names_a_managed_script`).
"""
if not isinstance(entry, dict):
return False
command = str(entry.get("command", "")).lower()
return any(marker in command for marker in _HOOK_COMMAND_MARKERS)
command = str(entry.get("command", ""))
if not any(marker in command.lower() for marker in _HOOK_COMMAND_MARKERS):
return False
if _references_managed_bin(command):
return True
return _names_a_managed_script(command, hooks_dir, hook_classification)
def _prune_hooks(hooks: Any) -> tuple[Any, bool]:
def _names_a_managed_script(
command: str, hooks_dir: Path, hook_classification: dict[str, bool | None]
) -> bool:
"""Whether ``command`` names, by absolute path, a hook script the map marks ``True``.
A real command is rarely the bare script path: ``bash <script>`` wraps
it, a shell often quotes it, and it may carry a redundant ``./`` segment.
``command`` is split into path-shaped tokens on the same boundary
punctuation :data:`_PATH_BOUNDARY_CHARS` (and whitespace) mark as *not*
part of a path, each token is lexically normalized, and compared against
the script's absolute path only.
Deliberately not resolved against ``~``/:func:`Path.home` or against the
process's working directory: a *relative* hook command in
``~/.claude/settings.json`` is resolved by the harness against the
project's cwd, not against home — this module never writes or inspects a
project-relative path, so treating one as if it named a home-relative
script would delete a project-local hook this purge has no business
touching. A relative token, and a ``$VAR``-style unexpanded reference
(e.g. ``$HOME/...``), are both simply not recognised unprovable, so
kept, per the guard's own rule.
"""
tokens = {
token
for haystack in (command, os.path.expanduser(command))
for token in _PATH_TOKEN_SPLIT.split(haystack)
if token
}
normalized_tokens = {posixpath.normpath(_norm_path_text(token)) for token in tokens}
return any(
verdict is True and _norm_path_text(str(hooks_dir / name)) in normalized_tokens
for name, verdict in hook_classification.items()
)
def _prune_hooks(
hooks: Any, hooks_dir: Path, hook_classification: dict[str, bool | None]
) -> tuple[Any, bool]:
"""Drop retired-tool entries from a ``hooks`` mapping; return ``(pruned, changed)``.
Handles both shapes Headroom's installers produced: Claude Code nests
@ -180,13 +506,17 @@ def _prune_hooks(hooks: Any) -> tuple[Any, bool]:
retained: list[Any] = []
for entry in entries:
# Cursor shape: the command sits on the entry itself.
if _references_context_tool(entry):
if _references_context_tool(entry, hooks_dir, hook_classification):
changed = True
continue
# Claude shape: a matcher entry holding a list of hooks.
inner = entry.get("hooks") if isinstance(entry, dict) else None
if isinstance(inner, list):
kept_inner = [item for item in inner if not _references_context_tool(item)]
kept_inner = [
item
for item in inner
if not _references_context_tool(item, hooks_dir, hook_classification)
]
if len(kept_inner) != len(inner):
changed = True
if not kept_inner:
@ -206,7 +536,9 @@ def _prune_hooks(hooks: Any) -> tuple[Any, bool]:
return pruned, changed
def _purge_hook_config(path: Path) -> list[str]:
def _purge_hook_config(
path: Path, hooks_dir: Path, hook_classification: dict[str, bool | None]
) -> list[str]:
"""Remove retired-tool hook registrations from a JSON hook config."""
if not path.is_file():
return []
@ -217,7 +549,7 @@ def _purge_hook_config(path: Path) -> list[str]:
if not isinstance(payload, dict):
return [f"skipped {path} (not a JSON object) — remove any stale hook by hand"]
hooks, changed = _prune_hooks(payload.get("hooks"))
hooks, changed = _prune_hooks(payload.get("hooks"), hooks_dir, hook_classification)
if not changed:
return []
if hooks:
@ -236,8 +568,10 @@ def _purge_mcp_entries(path: Path, container_key: str) -> list[str]:
``lean-ctx init`` registers lean-ctx as an MCP server in the harness's own
config Claude Code keeps them under ``mcpServers``, OpenCode under ``mcp``.
Only the exactly-named entries are removed; every other server, and every
unrelated top-level key, is preserved byte-for-byte.
A name match alone is not enough a user can register their own server
under the same name so an entry is removed only when its ``command``
also names Headroom's managed bin directory. Every other server, and
every unrelated top-level key, is preserved byte-for-byte.
"""
if not path.is_file():
return []
@ -251,7 +585,13 @@ def _purge_mcp_entries(path: Path, container_key: str) -> list[str]:
servers = payload.get(container_key)
if not isinstance(servers, dict):
return []
removed = [name for name in _MCP_SERVER_NAMES if name in servers]
removed = [
name
for name in _MCP_SERVER_NAMES
if isinstance(servers.get(name), dict)
and isinstance(servers[name].get("command"), str)
and _references_managed_bin(servers[name]["command"])
]
if not removed:
return []
for name in removed:

View file

@ -264,26 +264,29 @@
<template x-if="!stats.tokens?.output_reduction?.available">
<div class="mt-1 text-xs text-gray-500 leading-relaxed">
<span class="text-2xl font-light tabular-nums text-gray-600"></span>
<div class="mt-1">Enable the output shaper (HEADROOM_OUTPUT_SHAPER=1) and run
<div class="mt-1">Enable the beta output shaper (HEADROOM_ROLLOUT_CHANNEL=beta HEADROOM_OUTPUT_SHAPER=1) and run
<code class="text-gray-400">headroom learn --verbosity --apply</code> to start measuring.</div>
</div>
</template>
</div>
<!-- Tool-schema deferral: a COMPONENT of the Tokens Saved headline above
(stats.tokens.saved is all-layers), not a rival metric. Labelled as
such so a tool-heavy session doesn't read as "0 saved + some other
number". Only rendered when there's a saving to show. -->
<template x-if="(stats.savings?.by_layer?.tool_search?.tokens || 0) > 0">
<!-- Dynamic attribution for any named savings source. These
rows explain the headline; they are not added to it. -->
<template x-for="row in (stats.savings?.by_source || [])" :key="row.source + ':' + row.realized">
<div class="bg-surface rounded-lg p-4 border border-border">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Tokens Saved · Tool Schemas</div>
<div class="flex items-baseline gap-2">
<span class="text-3xl font-light tabular-nums text-emerald-400" x-text="formatNumber(stats.savings?.by_layer?.tool_search?.tokens || 0)"></span>
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1"
x-text="'Savings · ' + row.source.replaceAll('_', ' ')"></div>
<div class="flex items-baseline gap-2" x-show="(row.tokens || 0) > 0">
<span class="text-3xl font-light tabular-nums text-emerald-400" x-text="formatNumber(row.tokens || 0)"></span>
<span class="text-sm text-gray-400">tokens</span>
</div>
<div class="mt-1 text-xs text-gray-600 leading-relaxed"
x-text="formatNumber(stats.savings?.by_layer?.tool_search?.requests || 0) + ' calls · tool schemas deferred (recent window)'">
<div class="flex items-baseline gap-2" x-show="(row.usd || 0) !== 0">
<span class="text-2xl font-light tabular-nums"
:class="row.usd >= 0 ? 'text-emerald-400' : 'text-red-400'"
x-text="(row.usd >= 0 ? '$' : '-$') + formatCurrency(Math.abs(row.usd || 0))"></span>
</div>
<div class="mt-1 text-xs text-gray-600 leading-relaxed"
x-text="formatNumber(row.events || 0) + ' calls · ' + (row.realized ? 'realized' : 'projected')"></div>
</div>
</template>
</div>
@ -941,6 +944,17 @@
</div>
</div>
</template>
<template x-if="(req.savings_breakdown || []).length > 0">
<div class="mt-3">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Savings Attribution</div>
<div class="flex flex-wrap gap-1">
<template x-for="item in req.savings_breakdown" :key="item.source + ':' + item.tokens + ':' + item.usd">
<span class="px-2 py-0.5 bg-border rounded text-xs font-mono"
x-text="item.source + (item.tokens ? ' · ' + formatNumber(item.tokens) + ' tok' : '') + (item.usd ? ' · $' + formatCurrency(item.usd) : '') + (item.realized ? '' : ' · projected')"></span>
</template>
</div>
</div>
</template>
<!-- Waste Signals for this request -->
<template x-if="req.waste_signals && Object.keys(req.waste_signals).length > 0">
<div class="mt-3">
@ -1832,7 +1846,7 @@
savingsHistory: [],
expandedRows: {},
pollInterval: null,
statsPollMs: 15000,
statsPollMs: 5000,
viewPollMs: 30000,
feedPollMs: 5000,
viewRefreshers: {
@ -2152,7 +2166,9 @@
truncateModel(model) {
if (!model) return '-';
return model.replace(/^(anthropic\.|openai\.|bedrock\/)/, '')
return model.replace(/^(bedrock\/)/, '')
.replace(/^(au\.|us\.|eu\.|apac\.|global\.)/, '')
.replace(/^(anthropic\.|openai\.)/, '')
.replace(/-\d{8}$/, '')
.substring(0, 20);
},

View file

@ -31,7 +31,10 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .trained_router import TrainedRouter
from .trained_router import Technique
# Import the enum from the dependency-free module so importing the compressor
# does not eagerly import trained_router (and thus torch/transformers) — that
# eager import crashed on Python 3.13+ (#2513).
from .image_types import Technique
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,45 @@
"""Lightweight image-routing types shared across the image stack.
Kept dependency-free (pure enum + dataclasses, no torch / transformers / onnx)
so importing the image compressor or the ONNX router does not eagerly import
the heavy ML stack via ``trained_router``. On Python 3.13+ that eager import
crashed with ``AttributeError: module 'torch' has no attribute 'compiler'``
because ``transformers`` touched ``torch.compiler`` before torch finished
initializing inside the proxy process (#2513).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Technique(Enum):
"""Image optimization techniques."""
TRANSCODE = "transcode" # Convert to text description (99% savings)
CROP = "crop" # Extract relevant region (50-90% savings)
PRESERVE = "preserve" # Keep full quality (0% savings)
FULL_LOW = "full_low" # Full image, lower quality (87% savings)
@dataclass
class ImageSignals:
"""Signals extracted from image analysis."""
has_text: float
is_document: float
is_complex: float
has_small_details: float
@dataclass
class RouteDecision:
"""Result of routing decision."""
technique: Technique
confidence: float
reason: str
image_signals: ImageSignals | None = None
query_prediction: str | None = None
query_confidence: float | None = None

View file

@ -19,7 +19,7 @@ from typing import Any
import numpy as np
from headroom.image.trained_router import ImageSignals, RouteDecision, Technique
from headroom.image.image_types import ImageSignals, RouteDecision, Technique
from headroom.onnx_runtime import create_cpu_session_options, hf_hub_download_local_first
logger = logging.getLogger(__name__)

View file

@ -12,8 +12,6 @@ from __future__ import annotations
import gc
import io
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any
@ -28,6 +26,11 @@ except ImportError:
from headroom.models.config import ML_MODEL_DEFAULTS
# Re-exported from the dependency-free module so existing
# ``from .trained_router import Technique`` imports keep working without this
# module (which imports torch/transformers) being needed just for the types.
from .image_types import ImageSignals, RouteDecision, Technique
def _extract_tensor(output: torch.Tensor | BaseModelOutputWithPooling) -> torch.Tensor:
"""Extract tensor from model output.
@ -55,37 +58,6 @@ def _extract_tensor(output: torch.Tensor | BaseModelOutputWithPooling) -> torch.
return output
class Technique(Enum):
"""Image optimization techniques."""
TRANSCODE = "transcode" # Convert to text description (99% savings)
CROP = "crop" # Extract relevant region (50-90% savings)
PRESERVE = "preserve" # Keep full quality (0% savings)
FULL_LOW = "full_low" # Full image, lower quality (87% savings)
@dataclass
class ImageSignals:
"""Signals extracted from image analysis."""
has_text: float
is_document: float
is_complex: float
has_small_details: float
@dataclass
class RouteDecision:
"""Result of routing decision."""
technique: Technique
confidence: float
reason: str
image_signals: ImageSignals | None = None
query_prediction: str | None = None
query_confidence: float | None = None
class TrainedRouter:
"""Router using trained MiniLM classifier + SigLIP image analysis.

View file

@ -3,18 +3,21 @@
from __future__ import annotations
import shutil
import sys
from collections.abc import Iterable
import click
from headroom import paths as _paths
from headroom.providers.install_registry import build_install_target_envs
from headroom.rollout import RolloutChannel
from .models import (
ConfigScope,
DeploymentManifest,
InstallPreset,
ProviderSelectionMode,
RuntimeKind,
SupervisorKind,
ToolTarget,
)
@ -140,9 +143,19 @@ def build_manifest(
normalized_profile = validate_profile_name(profile)
if preset == InstallPreset.PERSISTENT_SERVICE.value:
# A Windows service must implement the Service Control Manager protocol.
# The Python runner is an ordinary console process, so registering it with
# ``sc.exe create`` always fails at start with SCM error 1053. Task
# Scheduler can run the same runner safely and already provides startup
# plus periodic health recovery, so make it the effective preset on
# Windows instead of creating a service that can never start (#2552).
effective_preset = preset
if sys.platform.startswith("win") and preset == InstallPreset.PERSISTENT_SERVICE.value:
effective_preset = InstallPreset.PERSISTENT_TASK.value
if effective_preset == InstallPreset.PERSISTENT_SERVICE.value:
supervisor_kind = SupervisorKind.SERVICE.value
elif preset == InstallPreset.PERSISTENT_TASK.value:
elif effective_preset == InstallPreset.PERSISTENT_TASK.value:
supervisor_kind = SupervisorKind.TASK.value
else:
supervisor_kind = SupervisorKind.NONE.value
@ -168,6 +181,26 @@ def build_manifest(
# defaults above (e.g. a custom HEADROOM_WORKSPACE_DIR).
if extra_env:
base_env.update(extra_env)
if intercept_tool_results:
configured_channel = base_env.get("HEADROOM_ROLLOUT_CHANNEL")
if configured_channel is None:
# The flag is an explicit canary opt-in. Persist the matching
# channel so the generated service can actually start.
base_env["HEADROOM_ROLLOUT_CHANNEL"] = RolloutChannel.CANARY.value
else:
channel = RolloutChannel.parse(configured_channel)
unsafe = base_env.get("HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES", "").lower() in {
"1",
"true",
"yes",
"on",
"enabled",
}
if not channel.allows(RolloutChannel.CANARY) and not unsafe:
raise click.ClickException(
"--intercept-tool-results requires HEADROOM_ROLLOUT_CHANNEL=canary "
"(or dev), unless the unsafe rollout override is explicitly enabled"
)
proxy_args = [
"--host",
@ -181,7 +214,19 @@ def build_manifest(
]
proxy_args.append("--telemetry" if telemetry_enabled else "--no-telemetry")
if memory_enabled:
proxy_args.extend(["--memory", "--memory-db-path", str(_paths.memory_db_path())])
proxy_args.append("--memory")
# `_paths.memory_db_path()` resolves against the HOST home. A container
# runtime cannot use it: the container's HOME is /tmp/headroom-home and
# the host's ~/.headroom is bind-mounted there, so a host path like
# /home/<user>/.headroom/memory.db does not exist inside the container,
# SQLite fails to open the DB, /readyz stays 503, and the deployment
# times out and rolls back (#2803). Omit the flag for a container runtime:
# the proxy then resolves the DB under its own cwd (.headroom/memory.db),
# which is the container's workdir and therefore the bind mount, landing
# in the same host file the explicit path intended. On the host (python)
# runtime the resolved host path is correct, so keep passing it.
if runtime_kind != RuntimeKind.DOCKER.value:
proxy_args.extend(["--memory-db-path", str(_paths.memory_db_path())])
if anyllm_provider:
proxy_args.extend(["--anyllm-provider", anyllm_provider])
if region:
@ -200,7 +245,7 @@ def build_manifest(
container_name = f"headroom-{normalized_profile}"
return DeploymentManifest(
profile=normalized_profile,
preset=preset,
preset=effective_preset,
runtime_kind=runtime_kind,
supervisor_kind=supervisor_kind,
scope=scope,

View file

@ -56,6 +56,32 @@ def _is_windows() -> bool:
return sys.platform.startswith("win")
def _container_runtime_is_podman() -> bool:
"""Best-effort: is the ``docker`` command actually Podman?
Rootless Podman maps the host user to container UID 0, so the
``--user <host-uid>:<host-gid>`` flag that is correct for Docker instead
selects a subordinate UID that owns none of the bind-mounted host
directories, and every write into ``~/.headroom`` fails (#2804). Detect the
common ``docker -> podman`` shim (e.g. NixOS
``/run/current-system/sw/bin/docker -> podman``) by resolving the binary and
checking its real name. ``HEADROOM_CONTAINER_RUNTIME`` (``podman`` / ``docker``)
is an explicit override for setups the symlink heuristic cannot see, such as a
wrapper script. No subprocess is spawned.
"""
override = os.environ.get("HEADROOM_CONTAINER_RUNTIME", "").strip().lower()
if override:
return override == "podman"
resolved = shutil.which("docker")
if not resolved:
return False
try:
real = os.path.realpath(resolved)
except OSError:
real = resolved
return "podman" in os.path.basename(real).lower()
def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
return {
"HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
@ -136,10 +162,18 @@ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
if docker_gpus:
command.extend(["--gpus", docker_gpus])
if not _is_windows():
getuid = getattr(os, "getuid", None)
getgid = getattr(os, "getgid", None)
if callable(getuid) and callable(getgid):
command.extend(["--user", f"{getuid()}:{getgid()}"])
if _container_runtime_is_podman():
# Rootless Podman maps the host user to container UID 0, so --user
# would map to a subordinate UID that owns none of the bind mounts and
# every write into ~/.headroom fails (#2804). keep-id maps the host
# user to the same UID inside the container, keeping the mounts
# writable. Docker maps UIDs 1:1, so --user stays correct there.
command.append("--userns=keep-id")
else:
getuid = getattr(os, "getuid", None)
getgid = getattr(os, "getgid", None)
if callable(getuid) and callable(getgid):
command.extend(["--user", f"{getuid()}:{getgid()}"])
runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
for name, value in runtime_env.items():
command.extend(["--env", f"{name}={value}"])

View file

@ -2,13 +2,17 @@
from __future__ import annotations
import getpass
import os
import re
import shlex
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
from xml.sax.saxutils import escape as _xml_escape
import click
@ -242,6 +246,97 @@ def _linux_task_spec(manifest: DeploymentManifest, ensure_script: Path) -> tuple
return None, content
def _windows_current_user() -> str:
"""Best-effort ``DOMAIN\\USER`` for the S4U task principal."""
user = os.environ.get("USERNAME") or getpass.getuser()
domain = os.environ.get("USERDOMAIN")
return f"{domain}\\{user}" if domain else user
def _windows_task_xml(command: str, *, trigger_xml: str, scope: str) -> str:
"""Render Task Scheduler XML that runs ``command`` without a visible window.
User-scope tasks use an S4U principal ("run whether user is logged on or
not", no stored password) so each run happens in a non-interactive session
and never draws a console window (issue #2453). System-scope tasks keep the
LocalSystem service account, which already has no desktop.
"""
if scope == "system":
principal = (
" <UserId>S-1-5-18</UserId>\n"
" <LogonType>ServiceAccount</LogonType>\n"
" <RunLevel>HighestAvailable</RunLevel>"
)
else:
principal = (
f" <UserId>{_xml_escape(_windows_current_user())}</UserId>\n"
" <LogonType>S4U</LogonType>\n"
" <RunLevel>LeastPrivilege</RunLevel>"
)
return (
'<?xml version="1.0" encoding="UTF-16"?>\n'
'<Task version="1.2" '
'xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
" <Triggers>\n"
f"{trigger_xml}\n"
" </Triggers>\n"
' <Principals>\n <Principal id="Author">\n'
f"{principal}\n"
" </Principal>\n </Principals>\n"
" <Settings>\n"
" <Hidden>true</Hidden>\n"
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n"
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n"
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n"
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n"
" <StartWhenAvailable>true</StartWhenAvailable>\n"
" </Settings>\n"
' <Actions Context="Author">\n'
f" <Exec>\n <Command>{_xml_escape(command)}</Command>\n </Exec>\n"
" </Actions>\n"
"</Task>\n"
)
def _windows_boot_trigger() -> str:
return " <BootTrigger>\n <Enabled>true</Enabled>\n </BootTrigger>"
def _windows_health_trigger() -> str:
start = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
return (
" <TimeTrigger>\n"
f" <StartBoundary>{start}</StartBoundary>\n"
" <Enabled>true</Enabled>\n"
" <Repetition>\n"
" <Interval>PT5M</Interval>\n"
" <StopAtDurationEnd>false</StopAtDurationEnd>\n"
" </Repetition>\n"
" </TimeTrigger>"
)
def _register_windows_task(name: str, xml: str) -> None:
"""Register ``xml`` as scheduled task ``name`` via ``schtasks /XML``."""
# schtasks reads the XML from a file; UTF-16 matches the declared encoding.
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".xml", encoding="utf-16", delete=False)
try:
tmp.write(xml)
tmp.close()
subprocess.run(
["schtasks", "/Create", "/TN", name, "/XML", tmp.name, "/F"],
check=True,
)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
"""Install service/task artifacts for the deployment."""
@ -345,35 +440,21 @@ def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
startup_name = f"{manifest.service_name}-startup"
health_name = f"{manifest.service_name}-health"
startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
user_args = ["/RU", "SYSTEM"] if manifest.scope == "system" else []
start_schedule = [
"schtasks",
"/Create",
"/TN",
# Register from task XML (not schtasks flags) so the principal is S4U /
# hidden — flag-created tasks use an interactive token and flash a
# focus-stealing console on every run (issue #2453).
_register_windows_task(
startup_name,
"/TR",
startup_cmd,
"/SC",
"ONSTART",
"/F",
*user_args,
]
health_schedule = [
"schtasks",
"/Create",
"/TN",
_windows_task_xml(
startup_cmd, trigger_xml=_windows_boot_trigger(), scope=manifest.scope
),
)
_register_windows_task(
health_name,
"/TR",
startup_cmd,
"/SC",
"MINUTE",
"/MO",
"5",
"/F",
*user_args,
]
subprocess.run(start_schedule, check=True)
subprocess.run(health_schedule, check=True)
_windows_task_xml(
startup_cmd, trigger_xml=_windows_health_trigger(), scope=manifest.scope
),
)
records.extend(
[
ArtifactRecord(kind="windows-task", path=startup_name),

View file

@ -49,6 +49,7 @@ except ImportError:
from headroom.ccr.tool_injection import CCR_TOOL_NAME
from headroom.config import is_tool_excluded
from headroom.telemetry.session import BeaconCompressionObserver
from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig
logger = logging.getLogger(__name__)
@ -136,7 +137,11 @@ class _CrusherSingleton:
config = SmartCrusherConfig(
min_tokens_to_crush=self._min_tokens,
)
self._crusher = SmartCrusher(config=config)
# observer: no proxy here, so nothing else reports these
# compressions to the beacon. See BeaconCompressionObserver.
self._crusher = SmartCrusher(
config=config, observer=BeaconCompressionObserver()
)
return self._crusher

View file

@ -94,6 +94,18 @@ class HeadroomCallback(_CustomLogger):
"""Whether cloud compression is enabled."""
return self._api_key is not None
async def aclose(self) -> None:
"""Close the shared cloud HTTP client, if it was initialized.
Applications using LiteLLM should await this method during their async
shutdown lifecycle. It is safe to call when cloud mode was not used or
after the client has already been closed.
"""
client = self._client
self._client = None
if client is not None:
await client.aclose()
async def async_pre_call_hook(
self,
user_api_key_dict: Any = None,

View file

@ -56,6 +56,7 @@ from typing import Any
from headroom.config import HeadroomConfig, SmartCrusherConfig
from headroom.providers.openai import OpenAIProvider
from headroom.telemetry.session import BeaconCompressionObserver
from headroom.transforms.smart_crusher import SmartCrusher
@ -263,7 +264,18 @@ class HeadroomMCPCompressor:
min_tokens_to_crush=profile.min_tokens_to_compress,
max_items_after_crush=profile.max_items,
)
crusher = SmartCrusher(config=smart_config, with_compaction=False) # type: ignore[arg-type]
# observer: MCP runs outside the proxy, so PrometheusMetrics (the
# proxy's observer, which forwards to the beacon) never sees these
# compressions. Without one, an MCP install reports real tokens.saved
# with an empty compression.by_strategy.
crusher = SmartCrusher(
# headroom.config.SmartCrusherConfig vs the transform's own
# same-named dataclass; the ignore has to sit on the argument line
# because that is where mypy reports a multi-line call's arg-type.
config=smart_config, # type: ignore[arg-type]
with_compaction=False,
observer=BeaconCompressionObserver(),
)
# Build messages for SmartCrusher (it expects conversation format)
messages = [

View file

@ -50,6 +50,7 @@ except ImportError:
from headroom import HeadroomConfig
from headroom.ccr.tool_injection import CCR_TOOL_NAME
from headroom.config import is_tool_excluded
from headroom.telemetry.session import BeaconCompressionObserver
from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig
logger = logging.getLogger(__name__)
@ -173,7 +174,11 @@ class HeadroomHookProvider(HookProvider): # type: ignore[misc]
crusher_config = SmartCrusherConfig(
min_tokens_to_crush=self.min_tokens_to_compress
)
self._crusher = SmartCrusher(config=crusher_config)
# observer: no proxy here, so nothing else reports these
# compressions to the beacon. See BeaconCompressionObserver.
self._crusher = SmartCrusher(
config=crusher_config, observer=BeaconCompressionObserver()
)
logger.debug(
"SmartCrusher initialized with min_tokens=%d", self.min_tokens_to_compress
)

View file

@ -85,6 +85,14 @@ def classify_error(content: str) -> ErrorCategory:
return ErrorCategory.UNKNOWN
# "exit code" only signals an error for a NONZERO code. Agent harnesses (Codex,
# Grok, opencode, ...) append "exit code 0" to every SUCCESSFUL shell command,
# so a bare "exit code" substring wrongly flagged those as errors and inflated
# the learned failure rate. Match a nonzero code (case-insensitive, so
# "Exit code: 1" counts too), never "exit code 0".
_NONZERO_EXIT_RE = re.compile(r"exit code:?\s*(?!0\b)\d", re.IGNORECASE)
def is_error_content(content: str) -> bool:
"""Heuristic: does this tool result look like an error?"""
if not content or len(content) < 10:
@ -105,10 +113,11 @@ def is_error_content(content: str) -> bool:
"auto-denied",
"Sibling tool call errored",
"timed out",
"exit code",
"FileNotFoundError",
]
return any(ind in snippet for ind in indicators)
if any(ind in snippet for ind in indicators):
return True
return bool(_NONZERO_EXIT_RE.search(snippet))
# =============================================================================

View file

@ -216,14 +216,15 @@ class GeminiPlugin(LearnPlugin, ConversationScanner):
usage = msg.get("usageMetadata", msg.get("usage", {}))
if isinstance(usage, dict):
# Gemini's promptTokenCount is the FULL input token count and
# cachedContentTokenCount is the cached SUBSET of it, so adding
# both double-counts the cached input. Likewise totalTokenCount
# == promptTokenCount + candidatesTokenCount, so
# (totalTokenCount - promptTokenCount) is just candidatesTokenCount
# again — adding it on top double-counts the output. Count the
# prompt as input and the candidates as output, once each.
total_input_tokens += usage.get("promptTokenCount", 0)
total_input_tokens += usage.get("cachedContentTokenCount", 0)
total_output_tokens += usage.get("candidatesTokenCount", 0)
total_output_tokens += (
usage.get("totalTokenCount", 0) - usage.get("promptTokenCount", 0)
if usage.get("totalTokenCount")
else 0
)
if not isinstance(parts, list):
continue
@ -312,7 +313,16 @@ class GeminiPlugin(LearnPlugin, ConversationScanner):
)
def _detect_project_path(self, session_path: Path) -> Path | None:
"""Try to detect the project path from a session file."""
"""Try to detect the project path from a session file (JSON or JSONL)."""
# A `.jsonl` session is a stream of one JSON object per line, so
# `json.load` on the whole file raises JSONDecodeError on the second
# line and detection silently fell back to cwd — writing the learned
# insights to the wrong project and missing its GEMINI.md. Read JSONL
# line-by-line like the sibling `_scan_jsonl_session` (and the Claude
# plugin's `_project_path_from_session_cwd`) do.
if session_path.suffix == ".jsonl":
return self._detect_project_path_jsonl(session_path)
try:
with open(session_path, encoding="utf-8", errors="replace") as f:
data = json.load(f)
@ -320,15 +330,39 @@ class GeminiPlugin(LearnPlugin, ConversationScanner):
return None
if isinstance(data, dict):
project_path = data.get("projectPath", data.get("project_path", ""))
if project_path and Path(project_path).exists():
return Path(project_path)
cwd = data.get("cwd", data.get("workingDirectory", ""))
if cwd and Path(cwd).exists():
return Path(cwd)
return self._project_path_from_entry(data)
return None
def _detect_project_path_jsonl(self, session_path: Path) -> Path | None:
try:
with open(session_path, encoding="utf-8", errors="replace") as f:
for line in f:
if not line.strip():
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
found = self._project_path_from_entry(entry)
if found is not None:
return found
except (OSError, UnicodeDecodeError):
return None
return None
@staticmethod
def _project_path_from_entry(entry: dict) -> Path | None:
project_path = entry.get("projectPath", entry.get("project_path", ""))
if project_path and Path(project_path).exists():
return Path(project_path)
cwd = entry.get("cwd", entry.get("workingDirectory", ""))
if cwd and Path(cwd).exists():
return Path(cwd)
return None
# Module-level instance for auto-discovery by the plugin registry
plugin = GeminiPlugin()

View file

@ -58,7 +58,15 @@ class GrokPlugin(LearnPlugin, ConversationScanner):
continue
decoded = unquote(workspace_dir.name)
project_path = Path(decoded) if decoded.startswith("/") else Path.cwd()
# The workspace dir name is a URL-encoded absolute cwd. Use
# Path.is_absolute() rather than a `startswith("/")` check so a
# Windows drive-letter path (e.g. `C:\Users\...`) is recognised as
# absolute instead of silently falling back to cwd (which would
# attribute the learnings to the wrong project and miss its
# GROK.md/AGENTS.md). Mirrors the Windows-aware path handling in
# memory/traffic_learner.py.
decoded_path = Path(decoded)
project_path = decoded_path if decoded_path.is_absolute() else Path.cwd()
agents_md = project_path / "AGENTS.md"
grok_md = project_path / "GROK.md"

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