Merge remote-tracking branch 'upstream/main' into maintain/pr-2585

# Conflicts:
#	headroom/proxy/cost.py
This commit is contained in:
JerrettDavis 2026-08-20 19:53:31 -05:00
commit cc46f172c9
297 changed files with 23530 additions and 2358 deletions

23
.cargo/audit.toml Normal file
View file

@ -0,0 +1,23 @@
# cargo-audit configuration for the Rust workspace.
#
# Path matters: cargo-audit reads `.cargo/audit.toml`, not a root-level
# `audit.toml`. A file at the repo root is silently ignored.
#
# The `audit` job in .github/workflows/rust.yml is a BLOCKING gate. It runs on
# every PR touching Rust and nightly on the schedule (the `rust-changes` job
# reports `rust=true` for `schedule`/`workflow_dispatch`, so a newly-disclosed
# advisory surfaces without anyone touching Rust code).
#
# It was `continue-on-error: true` until the change that added this file, which meant it reported findings
# nobody saw: RUSTSEC-2026-0258 (h2, unbounded empty DATA frames) sat in a green
# run. Anything ignored here has to be listed explicitly, with a reason.
[advisories]
ignore = [
# `paste` is unmaintained — an advisory of project status, not a
# vulnerability; there is no patched version to move to. It is transitive
# and unavoidable at our layer: tokenizers -> paste and rav1e -> paste,
# both reached via fastembed. Re-evaluate when tokenizers moves to
# `pastey` (the maintained drop-in fork).
"RUSTSEC-2024-0436",
]

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.34.0"
"version": "0.36.1"
},
"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.36.1",
"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

@ -1,3 +1,14 @@
# Copy this file to .env and fill in real values before running in production.
# IMPORTANT: Change NEO4J_AUTH before deploying — default credentials are insecure.
# Copy this file to .env and fill in real values before running.
# docker-compose.yml requires these — it will refuse to start with defaults.
# Neo4j credentials for the graph memory backend (format: user/password).
NEO4J_AUTH=neo4j/CHANGEME
# Password only, for library / non-Docker use of the Neo4j memory backend.
NEO4J_PASSWORD=CHANGEME
# Proxy token — gates the data plane whenever the proxy is not loopback-only.
# Generate: openssl rand -hex 32
HEADROOM_PROXY_TOKEN=CHANGEME
# Optional: set to 0.0.0.0 to expose the proxy on the network (requires a token).
# HEADROOM_BIND_ADDR=127.0.0.1

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.36.1"
},
"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.36.1",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

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
@ -273,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,
@ -288,6 +293,18 @@ jobs:
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
# `latest=false` is load-bearing (#3150). The action defaults to
# `latest=auto`, which appends a bare `latest` for any semver
# release — and it logs `suffixLatest=false`, so the per-tag
# `suffix=` below never reaches it. Every one of the 8 variant
# cells therefore pushed `ghcr.io/.../headroom:latest`, and the
# last cell to finish won. At 0.36.0 that was `code-slim`, so
# `:latest` resolved to the distroless build, whose
# `import onnxruntime` segfaults on arm64 — `headroom deploy`
# crash-looped on Apple Silicon. `:latest` has exactly one
# writer: the root-cell promotion step at the end of this job.
flavor: |
latest=false
tags: |
type=ref,event=branch,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=ref,event=pr,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
@ -306,6 +323,9 @@ jobs:
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
DIGEST_DIR: ${{ runner.temp }}/digests
# Read by the bare-`latest` guard below. Via `env:` rather than
# inline `${{ }}` so the value is never spliced into the script.
VARIANT_NAME: ${{ matrix.variant.name }}
run: |
# Reconstruct full image references from the digest marker
# filenames (each file is named after the bare hex digest
@ -325,6 +345,16 @@ jobs:
digest_refs+=("${IMAGE}@sha256:${digest}")
done
# Belt-and-braces for #3150: only the root cell may ever carry a
# bare `latest`. A suffixed variant reaching this point with one
# means the tag rules regressed, and shipping it would repoint
# `:latest` at a non-default image. Fail instead of publishing.
if [ -n "${VARIANT_NAME}" ] && jq -e '.tags[]? | select(endswith(":latest"))' \
<<< "${DOCKER_METADATA_OUTPUT_JSON}" >/dev/null 2>&1; then
echo "::error::variant '${VARIANT_NAME}' would publish a bare :latest tag" >&2
exit 1
fi
# Build `--tag` args from the metadata-action JSON output.
# Empty tags array is valid (PR builds without ref-tags
# enabled emit nothing); skip manifest creation in that case.

View file

@ -50,15 +50,26 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Prefer a short-lived, repo-scoped GitHub App installation token over a
# personal PAT. Gated on the repo variable so an unconfigured app simply
# falls through to the existing chain instead of breaking the release.
- name: Mint installation token
id: app-token
if: ${{ vars.RELEASE_APP_ID != '' }}
continue-on-error: true
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
ref: ${{ github.ref_name }}
# PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one:
# a push made with GITHUB_TOKEN does not trigger workflows, so the release
# PR's checks would never re-run against the synced commit and would stay
# red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual
# re-run of the PR's checks picks it up.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
# Do NOT persist the credential into .git/config. The next step runs
# scripts/version-sync.py *from the checked-out branch*, and this job
# triggers on a push to the unprotected glob release-please--branches--**.
# A persisted token would be readable by that script.
persist-credentials: false
- uses: actions/setup-python@v6
with:
@ -72,6 +83,14 @@ jobs:
run: python scripts/verify-versions.py
- name: Commit and push if anything changed
env:
# An app installation token if one was minted, else the existing
# chain. A PAT (not GITHUB_TOKEN) is still preferred here for the same
# reason release-please.yml wants one: a push made with GITHUB_TOKEN
# does not trigger workflows, so the release PR's checks would never
# re-run against the synced commit and would stay red. Supplied only
# to this step, after the branch-supplied script has already run.
SYNC_TOKEN: ${{ steps.app-token.outputs.token || secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: |
if git diff --quiet; then
echo "Already in sync — nothing to commit."
@ -81,7 +100,12 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore: sync generated version metadata"
# Push via an explicit remote URL because the checkout no longer
# persists credentials. Passed on stdin-free env expansion so the
# token is not written to the command line or into .git/config.
# This push re-triggers this workflow. version-sync.py is idempotent, so
# the next run finds no diff and exits above without pushing — the loop
# terminates after one no-op run.
git push origin HEAD:"${GITHUB_REF_NAME}"
git push \
"https://x-access-token:${SYNC_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
HEAD:"${GITHUB_REF_NAME}"

View file

@ -42,16 +42,31 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
# Prefer a short-lived, repo-scoped GitHub App installation token. A
# personal PAT carries the maintainer's whole account — with a classic
# `repo` scope that reaches every other repository they can access — and
# this credential can tag past branch protection and reaches PyPI, npm and
# GHCR through the `release: published` publishes. An installation token is
# scoped to this repository and expires in an hour. Gated on the repo
# variable so an unconfigured app falls through instead of blocking a
# release. See #2955.
- name: Mint installation token
id: app-token
if: ${{ vars.RELEASE_APP_ID != '' }}
continue-on-error: true
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: googleapis/release-please-action@v5
with:
# PAT (not GITHUB_TOKEN): a release/tag created by GITHUB_TOKEN does
# NOT emit events that trigger other workflows, so release.yml
# (PyPI/npm) and docker.yml — which fire on `release: published` —
# never ran, and releases had to be cut by hand. A PAT is treated as a
# real user, so the release it creates DOES trigger those publishes; it
# also lets the bot tag past branch/tag protection. Falls back to
# GITHUB_TOKEN when the secret is unset (the release PR still opens; it
# just won't trigger the downstream publishes).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
# Neither an app token nor a PAT is GITHUB_TOKEN, and that matters: a
# release/tag created by GITHUB_TOKEN does NOT emit events that trigger
# other workflows, so release.yml (PyPI/npm) and docker.yml — which fire
# on `release: published` — never ran, and releases had to be cut by
# hand. Falls back to GITHUB_TOKEN when nothing else is set (the release
# PR still opens; it just won't trigger the downstream publishes).
token: ${{ steps.app-token.outputs.token || secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: .release-please-config.json
manifest-file: .release-please-manifest.json

View file

@ -224,8 +224,12 @@ jobs:
uses: taiki-e/install-action@v2
with:
tool: cargo-audit,cargo-deny
- name: cargo audit (soft-fail)
continue-on-error: true
# Blocking. Soft-failing this made it useless: RUSTSEC-2026-0258 (h2,
# unbounded empty DATA frames -> unbounded memory or a panic) was
# reported by this job for as long as it existed and never turned a run
# red, so nobody acted on it. Accepted advisories go in audit.toml with
# a written reason rather than being swallowed wholesale here.
- name: cargo audit
run: cargo audit
- name: cargo deny check licenses
continue-on-error: true

View file

@ -0,0 +1,29 @@
name: tools-hash-refresh
# Enforce that headroom/tools.json SHA-256 pins match the published assets for
# the currently pinned tool versions. Fails if a version was bumped without
# refreshing pins (run scripts/refresh_tool_hashes.py locally). See WEB-03.
on:
pull_request:
paths:
- "headroom/tools.json"
- "scripts/refresh_tool_hashes.py"
- ".github/workflows/tools-hash-refresh.yml"
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch: {}
permissions:
contents: read
jobs:
verify-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Verify tool SHA-256 pins
run: python scripts/refresh_tool_hashes.py --check

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ scripts/*
!scripts/audit_wheel_glibc_symbols.py
!scripts/replay_codex_ws_load.py
!scripts/export_kompress_v2_onnx.py
!scripts/refresh_tool_hashes.py
!scripts/record_kompress_fixtures.py
!scripts/record_code_compressor_fixtures.py

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.22
rev: v0.16.2
hooks:
- id: ruff
args: [--fix]

View file

@ -7,8 +7,8 @@
"bump-patch-for-minor-pre-major": false,
"draft": false,
"prerelease": false,
"separate-pull-requests": false,
"pull-request-title-pattern": "chore: release ${version}",
"separate-pull-requests": true,
"pull-request-title-pattern": "chore: release${component} ${version}",
"packages": {
".": {
"package-name": "headroom-ai",

View file

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

View file

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

View file

@ -284,6 +284,235 @@ 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.36.1](https://github.com/headroomlabs-ai/headroom/compare/v0.36.0...v0.36.1) (2026-08-20)
### Bug Fixes
* **docker:** give :latest exactly one writer ([#3154](https://github.com/headroomlabs-ai/headroom/issues/3154)) ([bf651c3](https://github.com/headroomlabs-ai/headroom/commit/bf651c3dc1b8c43cca84d085b57528fa9c7de5cd))
* **metrics:** attribute tool-schema savings per model, not just compression ([#3155](https://github.com/headroomlabs-ai/headroom/issues/3155)) ([81fe9d5](https://github.com/headroomlabs-ai/headroom/commit/81fe9d534579d4dcac197ba901f65d6f19986d32))
* **security:** address u9up assessment findings (WEB-0107) ([#2207](https://github.com/headroomlabs-ai/headroom/issues/2207)) ([1f96dab](https://github.com/headroomlabs-ai/headroom/commit/1f96dabc19130947770353cd6e814db4fd96e6a0))
## [0.36.0](https://github.com/headroomlabs-ai/headroom/compare/v0.35.0...v0.36.0) (2026-08-20)
### Features
* add deterministic runtime rollout controls ([#1490](https://github.com/headroomlabs-ai/headroom/issues/1490)) ([3077ac8](https://github.com/headroomlabs-ai/headroom/commit/3077ac81e8ef3ddefebbe308ea37a4e9bb2100e6))
* **proxy:** let extensions report cost savings and their own latency ([#3051](https://github.com/headroomlabs-ai/headroom/issues/3051)) ([f9807fd](https://github.com/headroomlabs-ai/headroom/commit/f9807fd69e220f43068ec168515ae886dd36166f))
* **proxy:** unify savings attribution across stats, perf, metrics, and dashboard ([1b0b0b8](https://github.com/headroomlabs-ai/headroom/commit/1b0b0b89a4bf751c8bd592890aef9c3b339e8e37)), closes [#2976](https://github.com/headroomlabs-ai/headroom/issues/2976)
* **wrap/claude:** make the --1m fallback model configurable via HEADROOM_1M_MODEL ([#2983](https://github.com/headroomlabs-ai/headroom/issues/2983)) ([2a84725](https://github.com/headroomlabs-ai/headroom/commit/2a8472525d3a027c95dc38a10c4b6707b482cabc))
### Bug Fixes
* **anthropic:** honor the [1m] 1M-context tier, and price it correctly ([#3073](https://github.com/headroomlabs-ai/headroom/issues/3073)) ([6d2254d](https://github.com/headroomlabs-ai/headroom/commit/6d2254dfb5eb97f92249e0ee7aa04b2697adfa69))
* **ccr:** make --no-ccr disable server-side response handling too ([#3101](https://github.com/headroomlabs-ai/headroom/issues/3101)) ([131b119](https://github.com/headroomlabs-ai/headroom/commit/131b119c053e66fe825dabb3c242f6dc5c6049d7)), closes [#3082](https://github.com/headroomlabs-ai/headroom/issues/3082)
* **ccr:** make StreamingCCRHandler work on OpenAI streams ([#3069](https://github.com/headroomlabs-ai/headroom/issues/3069)) ([7ef736f](https://github.com/headroomlabs-ai/headroom/commit/7ef736fb1a8852a3dee52a362043c47084628a2a))
* **ccr:** only buffer a stream when a marker is actually redeemable ([#3092](https://github.com/headroomlabs-ai/headroom/issues/3092)) ([c502087](https://github.com/headroomlabs-ai/headroom/commit/c502087db702e9b6aa1d1736086cf4a69a7775e6))
* **ccr:** re-inject headroom_retrieve when history references it on the sessionless path ([942af56](https://github.com/headroomlabs-ai/headroom/commit/942af56f11cbd8466e25ae189c65ba56a9ddd602))
* **ccr:** relay a successful upstream turn when post-processing fails ([#3094](https://github.com/headroomlabs-ai/headroom/issues/3094)) ([0ec73fa](https://github.com/headroomlabs-ai/headroom/commit/0ec73faa2805502a5c13eab7e4f086f8ae2e175e))
* **ccr:** send Accept: application/json on a buffered stream:false turn ([#3102](https://github.com/headroomlabs-ai/headroom/issues/3102)) ([139c7cb](https://github.com/headroomlabs-ai/headroom/commit/139c7cbdde6a68ae3ade24341a79e5ba659c2cf3)), closes [#3078](https://github.com/headroomlabs-ai/headroom/issues/3078)
* **ccr:** verify a scanned marker's hash before advertising it ([#2908](https://github.com/headroomlabs-ai/headroom/issues/2908)) ([41dab2d](https://github.com/headroomlabs-ai/headroom/commit/41dab2d09925658b96fed492d534346ce1930f4c))
* **ci:** prevent native detector from hanging test shards ([#2996](https://github.com/headroomlabs-ai/headroom/issues/2996)) ([a708c05](https://github.com/headroomlabs-ai/headroom/commit/a708c0571eecfb53eaab6b787b7a6ace9b21c162))
* **ci:** scope the release credential and stop persisting it to disk ([#3062](https://github.com/headroomlabs-ai/headroom/issues/3062)) ([ac8646a](https://github.com/headroomlabs-ai/headroom/commit/ac8646aa3c6323c3c0b7051e09831f779859af6f))
* **ci:** unjam release and Docker publishing ([#2958](https://github.com/headroomlabs-ai/headroom/issues/2958)) ([e269afb](https://github.com/headroomlabs-ai/headroom/commit/e269afb935f298a833a189acfb8573e908b3b60b))
* **claude:** reject conflicting auth before proxy startup ([#2993](https://github.com/headroomlabs-ai/headroom/issues/2993)) ([2d88e31](https://github.com/headroomlabs-ai/headroom/commit/2d88e31a404e2be6c1c428deb2a387599eb820ba))
* **cli/install:** resolve the deployment profile instead of dead-ending on default ([#2832](https://github.com/headroomlabs-ai/headroom/issues/2832)) ([8252619](https://github.com/headroomlabs-ai/headroom/commit/82526191a103a8d0e079d170e47631b3c2bcb0d9))
* **cli:** stop the macOS malloc re-exec replacing an embedder's process ([#3064](https://github.com/headroomlabs-ai/headroom/issues/3064)) ([96c25f5](https://github.com/headroomlabs-ai/headroom/commit/96c25f518154536cf15f4e0b2d3fed80de6e67f6))
* **copilot:** route VS Code inline completions to Copilot, not OpenAI ([#3077](https://github.com/headroomlabs-ai/headroom/issues/3077)) ([204e751](https://github.com/headroomlabs-ai/headroom/commit/204e751d2f01b0e987e9c05edec21664bb2df279))
* **copilot:** send VS Code inline completions to the host that serves them ([#3112](https://github.com/headroomlabs-ai/headroom/issues/3112)) ([b77d612](https://github.com/headroomlabs-ai/headroom/commit/b77d61291399976985f12adcd6014aba2f0275cf))
* **deps:** bump datasets past PYSEC-2026-3716 ([#3136](https://github.com/headroomlabs-ai/headroom/issues/3136)) ([df6ff6b](https://github.com/headroomlabs-ai/headroom/commit/df6ff6bd5b47837c1247cf4eb8ac151ebd799aa5))
* **deps:** clear the two Rust advisories and make cargo audit blocking ([#3121](https://github.com/headroomlabs-ai/headroom/issues/3121)) ([93c474e](https://github.com/headroomlabs-ai/headroom/commit/93c474e84b2eeee147c274f3d75f48e5ea42d0d5))
* **deps:** raise the GitPython floor to 3.1.58 to clear 9 open advisories ([#3120](https://github.com/headroomlabs-ai/headroom/issues/3120)) ([8156d4d](https://github.com/headroomlabs-ai/headroom/commit/8156d4dc3a376476513ef6f78104ff81d08967ac))
* **docker:** publish compose ports on loopback only ([#3061](https://github.com/headroomlabs-ai/headroom/issues/3061)) ([481e0b8](https://github.com/headroomlabs-ai/headroom/commit/481e0b83d5393419b27b17d95767104c7c1bda26))
* **docker:** ship Bedrock auth and current registry ([#2982](https://github.com/headroomlabs-ai/headroom/issues/2982)) ([eafdf11](https://github.com/headroomlabs-ai/headroom/commit/eafdf11a2cea44aabc51ce59bbc031e0aaee9640))
* **doctor:** surface that Claude Desktop agent sessions bypass the proxy ([#2987](https://github.com/headroomlabs-ai/headroom/issues/2987)) ([be5b26d](https://github.com/headroomlabs-ai/headroom/commit/be5b26d807be81d83594c9144a8520f6f0f1b273))
* **install:** consolidate Windows fallback and cleanup safety ([#2980](https://github.com/headroomlabs-ai/headroom/issues/2980)) ([ddd2a25](https://github.com/headroomlabs-ai/headroom/commit/ddd2a259ecce4e57202a68a74a2c1adcb879679b))
* **install:** honor HEADROOM_PORT in install apply and deploy ([#3085](https://github.com/headroomlabs-ai/headroom/issues/3085)) ([58f28dc](https://github.com/headroomlabs-ai/headroom/commit/58f28dc7a6b6ce5bbf0f88524bd78cbe3f3ffa4b))
* **install:** stop the PowerShell installer leaking temp dirs into the real user PATH ([#2985](https://github.com/headroomlabs-ai/headroom/issues/2985)) ([ddd9f76](https://github.com/headroomlabs-ai/headroom/commit/ddd9f76729d5662201b84bd0a51281cd3ac64ad3))
* **learn:** include stdout in CLI failure messages, not just stderr ([#3080](https://github.com/headroomlabs-ai/headroom/issues/3080)) ([c5563d3](https://github.com/headroomlabs-ai/headroom/commit/c5563d3a7dd8b7f88767cf503f1b1696917e36ee))
* **mcp:** restore SDK v1 compatibility cap ([#2978](https://github.com/headroomlabs-ai/headroom/issues/2978)) ([6077e5a](https://github.com/headroomlabs-ai/headroom/commit/6077e5a149ee6548edaff033f2cdffffce6ea0cf))
* **memory:** sanitize entity_refs to prevent dict-shaped entries crashing search ([#2951](https://github.com/headroomlabs-ai/headroom/issues/2951)) ([2d1e96b](https://github.com/headroomlabs-ai/headroom/commit/2d1e96b85c61cc7aab821750f549f24d54cbb6f5))
* **onnx:** enforce Rust API-24 runtime compatibility ([#2979](https://github.com/headroomlabs-ai/headroom/issues/2979)) ([a3fe5cb](https://github.com/headroomlabs-ai/headroom/commit/a3fe5cb65bed625e2a6cb415821bd0798754ce08))
* **openclaw-plugin:** circuit breaker + per-request timeout for proxy resilience ([#639](https://github.com/headroomlabs-ai/headroom/issues/639)) ([6576ef6](https://github.com/headroomlabs-ai/headroom/commit/6576ef639cbb7be8bc5e6c25134956803d18f8d8))
* **opencode:** send x-headroom-project header on all proxied requests ([#2868](https://github.com/headroomlabs-ai/headroom/issues/2868)) ([eeb038b](https://github.com/headroomlabs-ai/headroom/commit/eeb038bc0c28fc8078986db0849bfcff6743c158))
* **policy:** price net-cost mutations with the 1h cache-write tier ([#2780](https://github.com/headroomlabs-ai/headroom/issues/2780)) ([ef7e07e](https://github.com/headroomlabs-ai/headroom/commit/ef7e07e0f5d6510ab96b5abb1698b1b681b5f9bf))
* **providers:** don't crash on a non-object HEADROOM_MODEL_LIMITS / models.json ([#3089](https://github.com/headroomlabs-ai/headroom/issues/3089)) ([3ed8f76](https://github.com/headroomlabs-ai/headroom/commit/3ed8f7601935cb08eebfd34007e97675903180a5))
* **proxy/anthropic:** don't buffer a CCR stream when passthrough discards the stream flip ([#2953](https://github.com/headroomlabs-ai/headroom/issues/2953)) ([f1c34d3](https://github.com/headroomlabs-ai/headroom/commit/f1c34d336cf35db341153c1c65e8c15219398340))
* **proxy/anthropic:** don't replay recorded prefix over live history ([#3026](https://github.com/headroomlabs-ai/headroom/issues/3026)) ([#3052](https://github.com/headroomlabs-ai/headroom/issues/3052)) ([c16be9b](https://github.com/headroomlabs-ai/headroom/commit/c16be9bbbec4aec6d4b35e482c166daef8afa72c))
* **proxy/anthropic:** repair headroom_retrieve history references the tools array cannot support ([#2876](https://github.com/headroomlabs-ai/headroom/issues/2876)) ([7de3573](https://github.com/headroomlabs-ai/headroom/commit/7de35739c61bed385dd078aee1b36865938c486d))
* **proxy/anthropic:** stop answering a non-streaming turn with an event stream ([#3142](https://github.com/headroomlabs-ai/headroom/issues/3142)) ([0e26fb8](https://github.com/headroomlabs-ai/headroom/commit/0e26fb80de600795e96435473486c4a7c79c6eaa))
* **proxy/cache:** strip cache_control from messages in the semantic cache key ([#3086](https://github.com/headroomlabs-ai/headroom/issues/3086)) ([2cae0f8](https://github.com/headroomlabs-ai/headroom/commit/2cae0f8eaf627f6b743deb215f7c19c499c26bcd))
* **proxy/gemini:** guard CCR continuation usage against present-null counts ([#3035](https://github.com/headroomlabs-ai/headroom/issues/3035)) ([a01897c](https://github.com/headroomlabs-ai/headroom/commit/a01897c791f4bb6471defafd560d29d491eb2df8))
* **proxy/openai:** propagate provider usage on the Responses WS-&gt;HTTP fallback ([#2988](https://github.com/headroomlabs-ai/headroom/issues/2988)) ([536c949](https://github.com/headroomlabs-ai/headroom/commit/536c949a692f4855719d71d612abc4968040286b))
* **proxy:** adapt 200 SSE upstream replies on buffered /v1/responses instead of 502 ([#2622](https://github.com/headroomlabs-ai/headroom/issues/2622)) ([d76fce0](https://github.com/headroomlabs-ai/headroom/commit/d76fce04a39b3f206e38a02e012d50b2c728f7ca))
* **proxy:** align signed-thinking wire accounting ([#3015](https://github.com/headroomlabs-ai/headroom/issues/3015)) ([b3f4436](https://github.com/headroomlabs-ai/headroom/commit/b3f443636d279d4bad845a8ef2bddb7ca50e9bc6))
* **proxy:** complete stateless Responses and buffered CCR lifecycle ([#2997](https://github.com/headroomlabs-ai/headroom/issues/2997)) ([8a1d38b](https://github.com/headroomlabs-ai/headroom/commit/8a1d38bc5da87b49a530df22090c3a156d2d0cd6))
* **proxy:** guard feedback endpoints and add CSRF checks to loopback writes ([#3060](https://github.com/headroomlabs-ai/headroom/issues/3060)) ([a6ab359](https://github.com/headroomlabs-ai/headroom/commit/a6ab359a5d8d67a85f734131b55dbcef768a821a))
* **proxy:** keep prefixed core tools resident ([#3046](https://github.com/headroomlabs-ai/headroom/issues/3046)) ([2f4d001](https://github.com/headroomlabs-ai/headroom/commit/2f4d001c9ffd7f856c8dab3e31a8240a1c676f04))
* **proxy:** preserve Codex WebSocket model attribution ([#3029](https://github.com/headroomlabs-ai/headroom/issues/3029)) ([a06a51e](https://github.com/headroomlabs-ai/headroom/commit/a06a51eca63f88271dfa77f2ee6bf3c8da6b24e4))
* **proxy:** relocate stray system-role messages to the top-level system param ([#765](https://github.com/headroomlabs-ai/headroom/issues/765)) ([#1357](https://github.com/headroomlabs-ai/headroom/issues/1357)) ([9fde127](https://github.com/headroomlabs-ai/headroom/commit/9fde12753416a6102535235b822e44afebf76e9e))
* **proxy:** restore the buffered-CCR heartbeat behind a grace window ([#3091](https://github.com/headroomlabs-ai/headroom/issues/3091)) ([a29d201](https://github.com/headroomlabs-ai/headroom/commit/a29d2015e5eaf72730a4155f0307cbfac1ea1c9b))
* **proxy:** scope the signed-thinking lock to blocks that actually changed ([#3124](https://github.com/headroomlabs-ai/headroom/issues/3124)) ([17522fb](https://github.com/headroomlabs-ai/headroom/commit/17522fb0a1013c012e8123b1e713dbb2f3e770d9))
* **proxy:** stop a lone surrogate turning a thinking body into a 500 ([#3134](https://github.com/headroomlabs-ai/headroom/issues/3134)) ([284ff31](https://github.com/headroomlabs-ai/headroom/commit/284ff31947ec9eac1de0e2dc1cf5de4933c29a50))
* **proxy:** stop cached responses replaying the producing turn's wire framing ([#3024](https://github.com/headroomlabs-ai/headroom/issues/3024)) ([9d37059](https://github.com/headroomlabs-ai/headroom/commit/9d370592b022d01e6bc44a88649a611507794776))
* **proxy:** stop operator secrets following a client-chosen upstream ([#3122](https://github.com/headroomlabs-ai/headroom/issues/3122)) ([05f5ef4](https://github.com/headroomlabs-ai/headroom/commit/05f5ef47cbc8b31a60458553d6bf240896a47e16))
* **proxy:** tune macOS libmalloc and trim allocator pages so long-lived RSS stays bounded ([#2879](https://github.com/headroomlabs-ai/headroom/issues/2879)) ([6d87825](https://github.com/headroomlabs-ai/headroom/commit/6d87825f62e47bc65eeae05fbb8a131d545fe5a2))
* **reporting:** show net vs gross savings, real skip thresholds, and the effective profile ([#3123](https://github.com/headroomlabs-ai/headroom/issues/3123)) ([250ede2](https://github.com/headroomlabs-ai/headroom/commit/250ede2f7f4752c0ab08831013fad3f753f4a578))
* tool_search_tool_regex deferred and falsely resolved on direct-Anthropic path ([#2971](https://github.com/headroomlabs-ai/headroom/issues/2971)) ([8ea87e7](https://github.com/headroomlabs-ai/headroom/commit/8ea87e7804abfbb55beaf869e50dcb66deab975a))
* **vscode:** persist compatible Claude modes and route Copilot CAPI ([#2986](https://github.com/headroomlabs-ai/headroom/issues/2986)) ([1aa701a](https://github.com/headroomlabs-ai/headroom/commit/1aa701adaa1ff792dd0e701f498d8d0326655670))
* **wrap:** set xAI upstream for grok-build proxy ([#2772](https://github.com/headroomlabs-ai/headroom/issues/2772)) ([c831081](https://github.com/headroomlabs-ai/headroom/commit/c8310819a4221b0d120436786fc499a24c8e55f1))
* **wrap:** stop the Serena pre-index stalling the launch path for 300s ([#2945](https://github.com/headroomlabs-ai/headroom/issues/2945)) ([6147883](https://github.com/headroomlabs-ai/headroom/commit/6147883d5e3a92cc7b890e6c05dce4391090c7e4))
* **wrap:** verify proxy deps before mutating Codex config ([#1628](https://github.com/headroomlabs-ai/headroom/issues/1628)) ([b7f342c](https://github.com/headroomlabs-ai/headroom/commit/b7f342c153a3e6e43a9d3df006bcd4dd69842d00))
### Performance Improvements
* **perf:** skip rotated logs outside the requested window ([#3081](https://github.com/headroomlabs-ai/headroom/issues/3081)) ([6c9f41e](https://github.com/headroomlabs-ai/headroom/commit/6c9f41e08c47f2bfc440c5a4c6ac8a357ad5ada0))
### Dependencies
* bump axum from 0.7.9 to 0.8.9 ([#2966](https://github.com/headroomlabs-ai/headroom/issues/2966)) ([5731be7](https://github.com/headroomlabs-ai/headroom/commit/5731be7e68f57292aed40d76e770657a88f78c13))
* bump criterion from 0.5.1 to 0.8.2 ([#2965](https://github.com/headroomlabs-ai/headroom/issues/2965)) ([b30f339](https://github.com/headroomlabs-ai/headroom/commit/b30f339d694abcd8dada76a34a1d69e30390bfc2))
* bump ruff from 0.15.22 to 0.16.2 in the pip-minor-patch group across 1 directory ([#2962](https://github.com/headroomlabs-ai/headroom/issues/2962)) ([ff17961](https://github.com/headroomlabs-ai/headroom/commit/ff17961cd76a7cea1cff0a9dcfb7338929f37c5a))
* bump sha2 from 0.10.9 to 0.11.0 ([#2288](https://github.com/headroomlabs-ai/headroom/issues/2288)) ([322425c](https://github.com/headroomlabs-ai/headroom/commit/322425c43bffde1ed0b64fecf3cf5951565dd82b))
* bump the cargo-minor-patch group across 1 directory with 4 updates ([#2964](https://github.com/headroomlabs-ai/headroom/issues/2964)) ([888a9f4](https://github.com/headroomlabs-ai/headroom/commit/888a9f4e147cf1f87244977fac81d5e9613352d7))
* bump tokio-tungstenite from 0.24.0 to 0.30.0 ([#2967](https://github.com/headroomlabs-ai/headroom/issues/2967)) ([bbe9013](https://github.com/headroomlabs-ai/headroom/commit/bbe901319d49a3d70caf7b37da2c29f7d7996e07))
* update mcp requirement from &lt;2.0.0,&gt;=1.28.1 to &gt;=1.28.1,&lt;3.0.0 ([#2963](https://github.com/headroomlabs-ai/headroom/issues/2963)) ([d6fb536](https://github.com/headroomlabs-ai/headroom/commit/d6fb5365f67b9b7f90c7c55caead16ca6b41c586))
## [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)

665
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -58,7 +58,7 @@ tracing = { version = "0.1", features = ["log"] }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
axum = "0.7"
axum = "0.8"
tower = "0.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
pyo3 = { version = "0.29", features = ["abi3-py310"] }

View file

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

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

@ -28,7 +28,7 @@ hf-hub = { version = "0.5", default-features = false, features = ["ureq", "rustl
md-5 = "0.10"
# `sha2` for `_hash_field_name` in smart_crusher (SHA256 truncated to 16
# hex chars). Python uses `hashlib.sha256` so we need byte-exact parity.
sha2 = "0.10"
sha2 = "0.11"
# `dashmap` for the CCR storage backend. Concurrent HashMap with sharded
# locking — distinct keys hashed to different shards never contend, so
# multi-worker proxy load doesn't queue on a single Mutex. Lock-free
@ -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

@ -135,11 +135,24 @@ pub(crate) const MAX_LOSSY_RATIO_SUBSCRIPTION: f32 = 0.25;
/// net-cost mutation formula (#856).
pub const CACHE_WRITE_MULTIPLIER: f32 = 1.25;
/// Anthropic prompt-cache write multiplier for the 1-hour TTL tier.
pub const CACHE_WRITE_MULTIPLIER_1H: f32 = 2.0;
/// Anthropic prompt-cache read multiplier: a `cache_read` token costs
/// 0.1× a plain input token. Input to the net-cost mutation formula
/// (#856).
pub const CACHE_READ_MULTIPLIER: f32 = 0.1;
/// Return the cache-write multiplier for a prompt-cache TTL tier.
///
/// Invalid, missing, and non-positive values retain the 5-minute default.
pub fn cache_write_multiplier_for_ttl(ttl_seconds: Option<f32>) -> f32 {
match ttl_seconds {
Some(ttl) if ttl.is_finite() && ttl >= 3_600.0 => CACHE_WRITE_MULTIPLIER_1H,
_ => CACHE_WRITE_MULTIPLIER,
}
}
/// Per-auth-mode policy that downstream compression stages consult.
///
/// `Copy` because the struct is small POD (two `bool`s + a `u32` + an
@ -269,7 +282,26 @@ impl CompressionPolicy {
expected_reads: f32,
p_alive: f32,
) -> f32 {
let w = CACHE_WRITE_MULTIPLIER;
self.net_mutation_gain_with_write_multiplier(
delta_t,
suffix_tokens,
expected_reads,
p_alive,
None,
)
}
/// Variant of [`Self::net_mutation_gain`] with an explicit cache-write
/// multiplier. `None` uses the 5-minute default.
pub fn net_mutation_gain_with_write_multiplier(
&self,
delta_t: u32,
suffix_tokens: u32,
expected_reads: f32,
p_alive: f32,
write_multiplier: Option<f32>,
) -> f32 {
let w = write_multiplier.unwrap_or(CACHE_WRITE_MULTIPLIER);
let r = CACHE_READ_MULTIPLIER;
// f32::max ignores NaN (returns the other operand), so NaN reads
// land on 0.0; clamp would propagate NaN, so guard alive explicitly.
@ -299,7 +331,32 @@ impl CompressionPolicy {
expected_reads: f32,
p_alive: f32,
) -> bool {
self.net_mutation_gain(delta_t, suffix_tokens, expected_reads, p_alive) > 0.0
self.should_mutate_deep_with_write_multiplier(
delta_t,
suffix_tokens,
expected_reads,
p_alive,
None,
)
}
/// Variant of [`Self::should_mutate_deep`] with an explicit cache-write
/// multiplier. `None` uses the 5-minute default.
pub fn should_mutate_deep_with_write_multiplier(
&self,
delta_t: u32,
suffix_tokens: u32,
expected_reads: f32,
p_alive: f32,
write_multiplier: Option<f32>,
) -> bool {
self.net_mutation_gain_with_write_multiplier(
delta_t,
suffix_tokens,
expected_reads,
p_alive,
write_multiplier,
) > 0.0
}
/// Remaining-read count at which a warm-cache (P_alive = 1)
@ -315,10 +372,21 @@ impl CompressionPolicy {
/// session lasts N more turns"). Returns 0 when `delta_t` is 0
/// (no savings — callers gate on `delta_t > 0`).
pub fn break_even_reads(&self, delta_t: u32, suffix_tokens: u32) -> f32 {
self.break_even_reads_with_write_multiplier(delta_t, suffix_tokens, None)
}
/// Variant of [`Self::break_even_reads`] with an explicit cache-write
/// multiplier. `None` uses the 5-minute default.
pub fn break_even_reads_with_write_multiplier(
&self,
delta_t: u32,
suffix_tokens: u32,
write_multiplier: Option<f32>,
) -> f32 {
if delta_t == 0 {
return 0.0;
}
let w = CACHE_WRITE_MULTIPLIER;
let w = write_multiplier.unwrap_or(CACHE_WRITE_MULTIPLIER);
let r = CACHE_READ_MULTIPLIER;
((w - r) / r) * ((suffix_tokens as f32) / (delta_t as f32))
}
@ -447,6 +515,29 @@ mod tests {
assert!(p.should_mutate_deep(50_000, 10_000, 3.0, 1.0));
}
#[test]
fn net_gain_big_shave_shallow_suffix_is_loss_at_1h_tier() {
let p = CompressionPolicy::for_mode(AuthMode::Payg);
let default_gain = p.net_mutation_gain(50_000, 10_000, 3.0, 1.0);
assert!(default_gain > 0.0, "default gain = {default_gain}");
let gain = p.net_mutation_gain_with_write_multiplier(
50_000,
10_000,
3.0,
1.0,
Some(CACHE_WRITE_MULTIPLIER_1H),
);
assert!((gain - (-4_000.0)).abs() < 1.0, "gain = {gain}");
assert!(!p.should_mutate_deep_with_write_multiplier(
50_000,
10_000,
3.0,
1.0,
Some(CACHE_WRITE_MULTIPLIER_1H),
));
}
#[test]
fn net_gain_no_suffix_edit_profitable_with_reads_remaining() {
// S = 0: nothing cached after the edit is invalidated. Warm-case

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
@ -230,6 +233,49 @@ impl BetaHeaderSticky {
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,
@ -539,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())
@ -548,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,
@ -622,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 {
@ -631,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,
@ -646,9 +745,13 @@ impl Config {
auth_mode_policy_enforcement: args.auth_mode_policy_enforcement,
strip_internal_headers: args.strip_internal_headers,
beta_header_sticky: args.beta_header_sticky,
enable_responses_streaming: args.enable_responses_streaming,
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,
@ -662,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),
@ -715,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,6 +28,13 @@ 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"
);

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use std::time::Instant;
use axum::body::{to_bytes, Body};
use axum::extract::{ConnectInfo, DefaultBodyLimit, State, WebSocketUpgrade};
use axum::extract::{ConnectInfo, DefaultBodyLimit, FromRequestParts, State, WebSocketUpgrade};
use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode, Uri};
use axum::response::IntoResponse;
use axum::routing::{any, get, post};
@ -25,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
@ -157,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
@ -188,14 +189,14 @@ pub fn build_app(state: AppState) -> Router {
// publisher endpoints look like
// `POST /v1beta1/projects/{p}/locations/{l}/publishers/anthropic/models/{m}:rawPredict`
// (and `:streamRawPredict`). The trailing `:<verb>` is awkward
// in axum's `:param` syntax, so we capture the entire trailing
// segment as `:model_action` and split on the last `:` inside
// in axum's `{param}` syntax, so we capture the entire trailing
// segment as `{model_action}` and split on the last `:` inside
// the dispatcher. Both verbs share the same axum route shape
// — matchit can't distinguish two patterns that overlap on the
// literal parameter. The verb dispatch lives in
// [`crate::vertex::handle_vertex_predict_dispatch`].
.route(
"/v1beta1/projects/:project/locations/:location/publishers/anthropic/models/:model_action",
"/v1beta1/projects/{project}/locations/{location}/publishers/anthropic/models/{model_action}",
post(crate::vertex::handle_vertex_predict_dispatch),
);
@ -218,11 +219,11 @@ pub fn build_app(state: AppState) -> Router {
// Bedrock handlers identically.
let bedrock_router: Router<AppState> = Router::new()
.route(
"/model/:model_id/invoke",
"/model/{model_id}/invoke",
post(crate::bedrock::invoke::handle_invoke),
)
.route(
"/model/:model_id/converse",
"/model/{model_id}/converse",
post(crate::bedrock::invoke::handle_invoke),
)
// PR-D2/PR-D5: streaming counterparts. Bedrock's protocol is
@ -234,11 +235,11 @@ pub fn build_app(state: AppState) -> Router {
// processing pipeline, so both route to the same handler.
// See `bedrock::invoke_streaming`.
.route(
"/model/:model_id/invoke-with-response-stream",
"/model/{model_id}/invoke-with-response-stream",
post(crate::bedrock::invoke_streaming::handle_invoke_streaming),
)
.route(
"/model/:model_id/converse-stream",
"/model/{model_id}/converse-stream",
post(crate::bedrock::invoke_streaming::handle_invoke_streaming),
)
.route_layer(axum::middleware::from_fn(
@ -280,18 +281,18 @@ pub fn build_app(state: AppState) -> Router {
post(crate::handlers::conversations::handle_conversations_create),
)
.route(
"/v1/conversations/:conversation_id",
"/v1/conversations/{conversation_id}",
get(crate::handlers::conversations::handle_conversations_get)
.post(crate::handlers::conversations::handle_conversations_update)
.delete(crate::handlers::conversations::handle_conversations_delete),
)
.route(
"/v1/conversations/:conversation_id/items",
"/v1/conversations/{conversation_id}/items",
post(crate::handlers::conversations::handle_conversations_items_create)
.get(crate::handlers::conversations::handle_conversations_items_list),
)
.route(
"/v1/conversations/:conversation_id/items/:item_id",
"/v1/conversations/{conversation_id}/items/{item_id}",
get(crate::handlers::conversations::handle_conversations_item_get)
.delete(crate::handlers::conversations::handle_conversations_item_delete),
);
@ -314,17 +315,22 @@ pub fn build_app(state: AppState) -> Router {
async fn catch_all(
State(state): State<AppState>,
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
ws: Option<WebSocketUpgrade>,
req: Request<Body>,
) -> Response<Body> {
if is_websocket_upgrade(req.headers()) {
if let Some(ws) = ws {
let (mut parts, body) = req.into_parts();
if is_websocket_upgrade(&parts.headers) {
// axum 0.8 requires optional extractors to opt in explicitly, and
// WebSocketUpgrade intentionally does not. Extract it only after the
// upgrade headers have identified this as a WebSocket request.
if let Ok(ws) = WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
let req = Request::from_parts(parts, body);
return ws_handler(ws, state, client_addr, req).await;
}
// Header says websocket but axum didn't extract it (likely missing
// Sec-WebSocket-Key) — fall through to HTTP forwarding which will
// surface the upstream error.
}
let req = Request::from_parts(parts, body);
forward_http(state, client_addr, req)
.await
.unwrap_or_else(|e| e.into_response())

View file

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

View file

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

View file

@ -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),

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,13 +10,26 @@
# 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:
# Ports published on the host — all bound to 127.0.0.1 (this machine only):
# 8787 proxy (OpenAI-compatible endpoint)
# 6333 Qdrant REST 6334 Qdrant gRPC
# 7474 Neo4j Browser 7687 Neo4j Bolt
#
# None of these three services authenticates inbound callers by default: the
# proxy's /v1/* data plane is open unless HEADROOM_PROXY_TOKEN is set, Qdrant
# has no API key, and Neo4j falls back to a published dev password. Publishing
# them on 0.0.0.0 therefore hands any peer on your network a relay through the
# proxy plus direct read/write on the embeddings and graph derived from your
# prompts. They are bound to loopback so that `docker compose up -d` is safe on
# a shared or untrusted network.
#
# To reach the proxy from another machine, publish it deliberately AND require
# a token — never one without the other:
# HEADROOM_PROXY_TOKEN=$(openssl rand -hex 32) # put this in .env
# ports: ["8787:8787"] # override in a compose override file
# =============================================================================
services:
@ -31,6 +44,11 @@ services:
command: ["--host", "0.0.0.0"]
environment:
- HEADROOM_HOST=0.0.0.0
# The proxy binds 0.0.0.0 *inside* the container (required for Docker port
# forwarding); it is confined to host loopback by the published port below.
# A proxy token is required so the data plane is never open if you widen the
# bind. Generate one with: openssl rand -hex 32
- HEADROOM_PROXY_TOKEN=${HEADROOM_PROXY_TOKEN:?set HEADROOM_PROXY_TOKEN (see .env.example; e.g. openssl rand -hex 32)}
- HOME=/home/nonroot
# Keep all Headroom read/write state on the named volume below.
- HEADROOM_WORKSPACE_DIR=/home/nonroot/.headroom
@ -38,8 +56,14 @@ services:
# if you want to use a custom OpenAI-compatible API endpoint,
# uncomment and set the following line with the desired URL
# - OPENAI_TARGET_API_URL=https://api.x.ai
# Required before publishing this port beyond loopback: without it the
# /v1/* data plane accepts unauthenticated callers.
# - HEADROOM_PROXY_TOKEN=${HEADROOM_PROXY_TOKEN}
ports:
- "8787:8787"
# Loopback-only. The container still listens on 0.0.0.0 (above) so the
# other compose services can reach it by name; this line controls only
# which host interfaces the port is published on.
- "127.0.0.1:8787:8787"
volumes:
- headroom_workspace:/home/nonroot/.headroom
# Readiness probe: the orchestrator polls /readyz so dependents and
@ -62,8 +86,10 @@ services:
qdrant:
image: qdrant/qdrant:v1.17.1
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC
# Loopback-only: Qdrant runs unauthenticated here and holds embeddings
# derived from your prompts.
- "127.0.0.1:6333:6333" # REST API
- "127.0.0.1:6334:6334" # gRPC
# Named volume keeps the vector index across container restarts/recreates.
volumes:
- qdrant_data:/qdrant/storage
@ -75,20 +101,19 @@ services:
neo4j:
image: neo4j:5.26
ports:
- "7474:7474" # HTTP (Browser)
- "7687:7687" # Bolt
# Loopback-only to keep the graph store off the network.
- "127.0.0.1:7474:7474" # HTTP (Browser)
- "127.0.0.1:7687:7687" # Bolt
# Named volume persists the graph data across container restarts/recreates.
volumes:
- neo4j_data:/data
environment:
# Credentials come from .env (NEO4J_AUTH=user/password). The default here
# is for LOCAL DEV ONLY — override it before exposing Neo4j anywhere.
- NEO4J_AUTH=${NEO4J_AUTH:-neo4j/devpassword}
# No default credential — must be supplied (see .env.example).
- NEO4J_AUTH=${NEO4J_AUTH:?set NEO4J_AUTH, e.g. neo4j/<strong-password>}
# APOC: Neo4j's standard procedure library, needed by Headroom's queries.
- NEO4J_PLUGINS=["apoc"]
- NEO4J_apoc_export_file_enabled=true
- NEO4J_apoc_import_file_enabled=true
- NEO4J_apoc_import_file_use__neo4j__config=true
# APOC file import/export stays disabled (its Neo4j default) — it grants
# filesystem read/write via stored procedures. Do not enable unless required.
# Named volumes — managed by Docker, survive `docker compose down` (use
# `docker compose down -v` to delete the stored data as well).

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

@ -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`)
@ -137,6 +151,30 @@ curl http://127.0.0.1:8787/v1/messages \
When `HEADROOM_STRIP_INTERNAL_HEADERS` is `enabled` (the default), the proxy
reads this header for routing and then strips it before forwarding upstream.
#### Configured secret headers are not sent to arbitrary upstreams
`ANTHROPIC_TARGET_API_HEADERS` / `OPENAI_TARGET_API_HEADERS` hold operator
secrets. Because `x-headroom-base-url` is chosen by the *client*, those headers
are only attached when the destination is one the operator designated:
- a host in the configured provider targets (`ANTHROPIC_TARGET_API_URL`,
`OPENAI_TARGET_API_URL`, and the Gemini/Vertex/Cloud Code equivalents), or
- a host listed in `HEADROOM_UPSTREAM_ALLOWED_HOSTS` (comma-separated).
A request to any other upstream is **still proxied** — it just does not carry
your configured headers, and the proxy logs
`upstream_extra_headers_withheld host=<host>` once per host. If you route to a
gateway via this header and need your configured headers to reach it, add its
host to `HEADROOM_UPSTREAM_ALLOWED_HOSTS`:
```bash
export HEADROOM_UPSTREAM_ALLOWED_HOSTS="gateway.internal,api.example-gateway.ai"
```
Matching is on the parsed hostname and is exact — no wildcards — so
`api.anthropic.com.evil.example` and `https://api.anthropic.com@evil.example`
do not match `api.anthropic.com`.
## SmartCrusher Configuration
Fine-tune JSON compression behavior:
@ -196,6 +234,24 @@ response = client.chat.completions.create(
The `RollingWindowConfig`, `IntelligentContextConfig`, and `ScoringWeights` classes are no longer part of Headroom. Context management now happens automatically inside the pipeline (live-zone-only compression).
### Claude 1M context window (`headroom wrap claude --1m`)
`headroom wrap claude --1m` opts a Claude Code session into Anthropic's 1M-token context window by selecting a `[1m]`-suffixed model id, which makes Claude Code send the `context-1m` beta header. The model that `--1m` targets is resolved in this order:
1. an explicit `--model` / `ANTHROPIC_MODEL` value (used as-is, with a `[1m]` suffix appended when missing),
2. otherwise `HEADROOM_1M_MODEL`, when set,
3. otherwise the built-in default (currently `claude-opus-5`).
Set `HEADROOM_1M_MODEL` to point `--1m` at a specific model without pinning `ANTHROPIC_MODEL` globally, so the default can follow a new Opus generation without a code change:
```bash
# Route --1m at a specific model for this shell / session
export HEADROOM_1M_MODEL=claude-opus-5
headroom wrap claude --1m
```
`HEADROOM_1M_MODEL` is a fallback only: an explicit `--model` or `ANTHROPIC_MODEL` always wins. The value may be given with or without the `[1m]` suffix; both `claude-opus-5` and `claude-opus-5[1m]` are accepted, and the suffix is added when absent.
## Pipeline Extensions
Use a `headroom.pipeline_extension` entry point when you need to normalize or annotate requests before they leave Headroom. The `PRE_SEND` stage is the right place for provider-specific request cleanup, such as turning `content: null` into `content: ""` for upstreams that reject OpenAI-spec tool-call messages.
@ -303,6 +359,7 @@ headroom proxy --learn --min-evidence 3
| `HEADROOM_DEDUPE` | Whole-conversation verbatim cross-turn dedup in the router (cache-safe, information-preserving via retrieval markers). Superseded-read drop + lossless folds run without it; this adds verbatim dedup. | `off` |
| `HEADROOM_CACHE_TTL_LEARN` | Append per-turn cache-outcome observations (provider, model, idle, hit/miss) to `cache_ttl_observations.jsonl` for the offline `headroom-cache-ttl` learner. Observation-only (no request-behavior change); respects `HEADROOM_STATELESS`; the log is size-bounded. | `off` |
| `HEADROOM_KOMPRESS_ENDPOINT` / `HEADROOM_KOMPRESS_ENDPOINT_TOKEN` | Offload ML compression (Kompress) to a remote endpoint instead of the local ONNX model — used by reasoning compaction and the router when set. | -- |
| `HEADROOM_1M_MODEL` | Fallback model that `headroom wrap claude --1m` targets when neither `--model` nor `ANTHROPIC_MODEL` is set. Accepts the id with or without the `[1m]` suffix (added when absent); an explicit `--model` / `ANTHROPIC_MODEL` always wins. See [Claude 1M context window](#claude-1m-context-window-headroom-wrap-claude---1m). | `claude-opus-5` |
For provider-only proxying, prefer `HEADROOM_HTTP_PROXY` over process-wide variables such as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, or `NO_PROXY`. HTTPX reads those global variables, but Headroom also passes them through to tool executions.

View file

@ -58,6 +58,7 @@
"architecture",
"ci-cd-flows",
"releases",
"runtime-rollouts",
"benchmarks",
"limitations",
"---Help---",

View file

@ -71,7 +71,7 @@ curl -s http://127.0.0.1:8787/v1/models \
Output shaping makes the model's responses shorter — fewer tokens, lower cost:
```bash
HEADROOM_OUTPUT_SHAPER=1 HEADROOM_VERBOSITY_LEVEL=2 \
HEADROOM_ROLLOUT_CHANNEL=beta HEADROOM_OUTPUT_SHAPER=1 HEADROOM_VERBOSITY_LEVEL=2 \
headroom proxy --port 8787 --openai-api-url https://api.deepseek.com/v1
```

View file

@ -78,6 +78,8 @@ 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).
Because this header is client-driven, operator-configured secret headers (`OPENAI_TARGET_API_HEADERS` / `ANTHROPIC_TARGET_API_HEADERS`) are only attached when the resolved upstream host is one you designated — a configured provider target, or a host in `HEADROOM_UPSTREAM_ALLOWED_HOSTS`. Other upstreams are still routed to, just without those headers. See [Configuration](/docs/configuration) for details.
## 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:

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
@ -284,6 +284,7 @@ Rewrite the upstream model per request — for example, send small, tool-free ca
| `--telemetry` / `HEADROOM_TELEMETRY` | off | **Local-only** usage stats for your own `/stats`, `/metrics`, and dashboard. Nothing leaves the machine. |
| `--log-file` / `HEADROOM_LOG_FILE` | none | JSONL request/response log. |
| `--log-messages` | `false` | Include full message bodies in the log (may contain sensitive data). |
| `HEADROOM_LOG_LEVEL` | `warning` | uvicorn's log level (`critical`, `error`, `warning`, `info`, `debug`, `trace`). Raise to `info` for the per-request access log when diagnosing a deployed proxy. An unrecognized value warns and falls back to `warning`. |
| `HEADROOM_OTEL_METRICS_ENABLED` | `false` | Export OpenTelemetry metrics (`HEADROOM_OTEL_METRICS_ENDPOINT`, …). See [OTLP export](/docs/metrics#opentelemetry-otlp-export). |
| `HEADROOM_LANGFUSE_ENABLED` | `false` | Emit Langfuse traces (`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`). |

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

@ -45,8 +45,8 @@ The command:
1. validates Copilot subscription access and resolves the account API endpoint;
2. starts Headroom on `127.0.0.1:8787` with the short-lived upstream token;
3. adds a marker-owned block to VS Code user settings containing
`github.copilot.advanced.debug.overrideProxyUrl` and
`github.copilot.advanced.debug.overrideAuthType`;
`github.copilot.advanced.debug.overrideProxyUrl` (inline completions) and
`github.copilot.advanced.debug.overrideCapiUrl` (chat);
4. keeps running until `Ctrl+C` so the local proxy is available to VS Code.
Continue using Copilot's normal model picker. The request body—and therefore the

View file

@ -0,0 +1,223 @@
# Headroom Metrics — Dashboard Guide
What each metric shows, so you can build panels against it.
**Two endpoints.** Both are on the proxy (default `:8787`).
| Surface | How to get it | Use it for |
|---|---|---|
| **Prometheus**`GET /metrics` | Always on, no config | Everything below. Start here. |
| **OpenTelemetry** — OTLP/HTTP | `HEADROOM_OTEL_METRICS_ENABLED=1` + `pip install "headroom-ai[proxy,otel]"` | Same data, dotted names, plus per-tenant labels |
Names differ between them: Prometheus uses `headroom_tokens_saved_total` (**milliseconds** for timings), OTel uses `headroom.proxy.tokens.saved` (**seconds**). Both are listed below.
---
## The savings panel — start here
**`headroom.proxy.tokens.saved`** is the headline number. It already combines compression + tool-schema deferral — no need to add anything to it.
| Metric | What it shows |
|---|---|
| **`headroom.proxy.tokens.saved`** *(OTel)* | **Total input tokens Headroom kept out of the request.** Compression + tool savings, combined. This is your hero number. |
| `headroom.proxy.savings.usd{source}` *(OTel)* | **Dollars saved**, split by layer: `compression`, `tool_schema`, `output_shaping`, `provider_cache`. Sum for the total. |
| `headroom_persistent_savings_tokens_saved_total` | Same tokens-saved number, but **survives proxy restarts**. Use for "lifetime saved" tiles. |
| `headroom_persistent_savings_compression_savings_usd_total` | **Lifetime dollars saved**, durable across restarts. |
| `headroom_tokens_input_total` | Input tokens actually sent upstream (post-compression). The denominator for a reduction %. |
| `headroom_tokens_output_total` | Output tokens returned by the provider. |
```promql
# Hero tile: tokens saved per second
rate(headroom_tokens_saved_total[5m])
+ sum(rate(headroom_savings_attributed_tokens_total{source="tool_search",realized="true"}[5m]))
# Context reduction %
100 * rate(headroom_tokens_saved_total[5m])
/ clamp_min(rate(headroom_tokens_input_total[5m]) + rate(headroom_tokens_saved_total[5m]), 1)
# Lifetime tiles (survive restart)
headroom_persistent_savings_tokens_saved_total
headroom_persistent_savings_compression_savings_usd_total
```
> **One catch on the Prometheus side.** `headroom_tokens_saved_total` is compression **only** — it leaves out tool-schema deferral. The OTel `headroom.proxy.tokens.saved` includes both. That's why the query above adds the `tool_search` term back in. On tool-heavy workloads the gap is large.
---
## Latency panel
All Prometheus timings are in **milliseconds**, exposed as `_sum` / `_count` / `_min` / `_max`. Build means with `rate(sum)/rate(count)`.
| Metric | What it shows |
|---|---|
| **`headroom_overhead_ms_*`** | **Latency Headroom itself adds.** Handler entry → end of compression. Excludes the LLM call. This is the "what does this cost us" number. |
| `headroom_latency_ms_*` | Total request duration, including the provider. |
| `headroom_ttfb_ms_*` | Time to first byte from upstream. Streaming requests only. |
| `headroom_stage_timing_ms_*{path,stage}` | Where time went inside the handler — `compression_first_stage`, `upstream_connect`, `memory_context`, etc. |
| `headroom_transform_timing_ms_*{transform}` | Time per compression transform. Use to find a slow transform. |
```promql
# Headroom's added overhead, mean ms
rate(headroom_overhead_ms_sum[5m]) / rate(headroom_overhead_ms_count[5m])
# End-to-end, mean ms
rate(headroom_latency_ms_sum[5m]) / rate(headroom_latency_ms_count[5m])
# Slowest stages
topk(5, rate(headroom_stage_timing_ms_sum[5m]) / rate(headroom_stage_timing_ms_count[5m]))
```
> **No percentiles are available.** There are no histogram buckets on `/metrics`, and the OTel histograms ship with default buckets that put every request into one bucket, so `histogram_quantile()` returns nonsense. **Means work fine.** For real p95/p99 today, use the `headroom perf` CLI.
>
> Also: divide each `_sum` by **its own** `_count`. Overhead and TTFB are only sampled when > 0, so their counts are smaller than the latency count.
---
## Cache panel
| Metric | What it shows |
|---|---|
| `headroom_provider_cache_hit_requests_total{provider}` | Requests that read from the provider's prompt cache. |
| `headroom_provider_cache_requests_total{provider}` | Requests with any cache activity. **The correct denominator for hit rate.** |
| `headroom_cache_read_tokens_total{provider}` | Tokens served from cache (the discounted ones). |
| `headroom_cache_write_tokens_total{provider}` | Tokens written into cache (these carry a premium). |
| `headroom_cache_write_ttl_tokens_total{provider,ttl}` | Cache writes split by TTL — `5m` vs `1h`. |
| `headroom_uncached_input_tokens_total{provider}` | Input tokens that missed cache entirely. |
| `headroom_cache_bust_total` | Requests where compression broke a cached prefix. **Should stay near zero.** |
| `headroom_cache_miss_attribution_total{provider,reason}` | Why a cached prefix missed — `ttl_expiry`, `prefix_change`, `unknown`. |
```promql
# Cache hit rate by provider
sum by (provider) (rate(headroom_provider_cache_hit_requests_total[5m]))
/ sum by (provider) (rate(headroom_provider_cache_requests_total[5m]))
# Compression breaking cache — alert if this rises
rate(headroom_cache_bust_total[5m])
```
> **Don't use `headroom_requests_cached_total` as a hit rate.** It mixes the provider's prompt cache with Headroom's own response cache into one boolean, so it measures neither.
---
## Traffic & health panel
| Metric | What it shows |
|---|---|
| `headroom_requests_total` | Requests handled. Unlabelled. |
| `headroom_requests_by_provider{provider}` | Traffic split by provider — `anthropic`, `openai`, `gemini`, `bedrock`… |
| `headroom_requests_by_model{model}` | Traffic split by model. Capped at 1024 distinct; overflow lands in `model="other"`. |
| `headroom_requests_failed_total` | Upstream 5xx errors. |
| `headroom_requests_rate_limited_total` | Requests **Headroom** rejected via its own rate limiter (not upstream 429s). |
| `headroom_compression_failed_total{reason}` | Compression failures — `timeout` or `error`. Fails open, so traffic keeps flowing but savings quietly stop. **Worth an alert.** |
| `headroom_compression_quarantine_total{event}` | Compression disabled after repeated timeouts — `activated`, `skipped`, `released`. |
| `headroom_inbound_requests_active` | In-flight requests, gauge. Counts all HTTP including `/metrics`. |
| `headroom_active_ws_sessions` | Live Codex WebSocket sessions, gauge. |
```promql
# Failure rate
rate(headroom_requests_failed_total[5m])
/ clamp_min(rate(headroom_requests_total[5m]) + rate(headroom_requests_failed_total[5m]), 1)
# Savings silently stopped
sum by (reason) (rate(headroom_compression_failed_total[5m]))
# Traffic mix
sum by (provider) (rate(headroom_requests_by_provider[5m]))
```
---
## Anthropic subscription panel
Only if you're on an Anthropic OAuth/subscription plan. OTel only, gauges, no labels.
| Metric | What it shows |
|---|---|
| `headroom.subscription.5h_utilization_pct` | How much of the 5-hour rate-limit window is used (0100). |
| `headroom.subscription.7d_utilization_pct` | Same for the 7-day window. |
| `headroom.subscription.5h_seconds_to_reset` | Seconds until the 5-hour window resets. |
| `headroom.subscription.7d_seconds_to_reset` | Seconds until the 7-day window resets. |
| `headroom.subscription.overage_usd` | Extra-usage credits consumed, in dollars. |
---
## Attribution — where savings came from
| Metric | What it shows |
|---|---|
| `headroom_savings_attributed_tokens_total{source,realized}` | Tokens saved, broken out by named source. `source="tool_search"` is tool-schema deferral. |
| `headroom_savings_attributed_usd_total{source,realized}` | Dollars saved by source. **Gauge, can go negative** — don't `rate()` it. |
| `headroom_savings_attribution_events_total{source,realized}` | How often each source contributed. |
| `headroom_waste_signal_tokens_total{signal}` | Wasteful patterns *detected* in the input — `json_bloat`, `base64`, `repetition`, `reread`… This is diagnosis, **not savings**. |
These rows *explain* the headline total — they are never added to it.
---
## Compression internals
| Metric | What it shows |
|---|---|
| `headroom.compression.tokens.input` *(OTel)* | Tokens going into the compression pipeline. |
| `headroom.compression.tokens.output` *(OTel)* | Tokens coming out. |
| `headroom.compression.tokens.saved` *(OTel)* | The difference. Pipeline-level view of compression only. |
| `headroom.compression.runs` *(OTel)* | Pipeline executions. Note: **per pipeline run, not per request.** |
| `headroom.compression.pipeline.duration` *(OTel, seconds)* | How long the pipeline took. |
| `headroom.compression.transforms{transform}` *(OTel)* | Which transforms fired. **High cardinality — drop or aggregate at the collector.** |
---
## Five things that will break a dashboard
1. **Only savings counters survive a restart.** 55 of 60 Prometheus families reset to zero when the proxy restarts. Only `headroom_persistent_savings_*` is durable, and it needs `HEADROOM_WORKSPACE_DIR` on a persistent volume — otherwise it resets on every deploy.
2. **No percentiles anywhere.** Use means. See the latency section.
3. **`headroom_latency_ms` measures differently for streaming.** On streaming requests the timer starts *after* compression, so end-to-end is `latency + overhead`. On non-streaming it's just `latency`. Don't mix both in one panel.
4. **A 5xx erases its own savings.** Requests that fail upstream are dropped from every savings and token counter. During a provider incident, savings rates look artificially clean while throughput falls.
5. **`/metrics` needs auth if you set a proxy token.** With `HEADROOM_PROXY_TOKEN` set, any non-loopback scraper must send `Authorization: Bearer <token>`. Loopback is always exempt.
---
## Metrics the docs mention that don't exist
If panels came back empty, this is probably why. These names appear in the published docs but not in the code:
`headroom_compression_ratio` · `headroom_latency_seconds` (and `_bucket`) · `headroom_cache_hits_total` · `headroom_cache_misses_total` · `headroom_cost_usd_total` · the `mode="optimize"` label on `headroom_requests_total`
The shipped `examples/grafana/headroom-dashboard.json` also filters every panel on `pool` and `hook` labels that no metric emits — the dropdowns will be permanently empty. Its metric names are otherwise correct.
---
## Setup reference
```bash
# Prometheus — nothing to do, GET /metrics is always on
# OpenTelemetry
pip install "headroom-ai[proxy,otel]"
export HEADROOM_OTEL_METRICS_ENABLED=1
export HEADROOM_OTEL_METRICS_ENDPOINT=https://otel.corp.example/v1/metrics
export HEADROOM_OTEL_METRICS_HEADERS="authorization=Bearer XXX"
export HEADROOM_OTEL_RESOURCE_ATTRIBUTES="service.instance.id=$HOSTNAME"
```
| Variable | Default | Notes |
|---|---|---|
| `HEADROOM_OTEL_METRICS_ENABLED` | `0` | Master switch |
| `HEADROOM_OTEL_METRICS_EXPORTER` | `otlp_http` | Or `console`. No gRPC exporter exists. |
| `HEADROOM_OTEL_METRICS_ENDPOINT` | unset | Passed verbatim — `/v1/metrics` is **not** appended |
| `HEADROOM_OTEL_METRICS_HEADERS` | unset | `k=v,k2=v2` |
| `HEADROOM_OTEL_METRICS_EXPORT_INTERVAL_MS` | `10000` | |
| `HEADROOM_OTEL_SERVICE_NAME` | `headroom-proxy` | |
| `HEADROOM_OTEL_RESOURCE_ATTRIBUTES` | unset | **Set `service.instance.id` here** — Headroom doesn't, and replicas will collide |
Verify with `curl -s localhost:8787/stats | jq .otel`.
**Multi-tenant labels:** `register_otel_metric_attribute_provider()` adds request-scoped attributes (tenant, team, cost centre) to every OTel datapoint. Max 16 attributes, 256 chars each.
**Air-gapped deployments:** `HEADROOM_OFFLINE=1` disables all outbound traffic — the anonymous usage beacon (which is **on by default**), the update check, and model downloads.
---

View file

@ -791,8 +791,14 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"VS Code wrap should configure the project-scoped proxy URL",
)
assert_true(
'"github.copilot.advanced.debug.overrideAuthType": "token"' in configured,
"VS Code wrap should configure token auth",
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(
"overrideAuthType" not in configured,
"VS Code wrap must not write overrideAuthType: no such setting exists in "
"the modern Copilot Chat extension, so VS Code flags it as unknown (#3076)",
)
assert_true(
"synthetic-e2e-token" not in configured, "Settings must not contain credentials"
@ -849,8 +855,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

@ -207,27 +207,43 @@ _PROFILES: dict[str, AgentSavingsProfile] = {
def get_agent_savings_profile(name: str | None = None) -> AgentSavingsProfile:
"""Return a named agent savings profile.
An unrecognized name falls back to the ``balanced`` profile with a warning
instead of raising. The savings profile is a soft config knob, but it is
resolved during proxy startup (``proxy_pipeline_kwargs`` -> ``create_app``),
so raising here takes the whole proxy down before it can open its port. That
happens on desktop/runtime version skew: a newer client requests a profile
(e.g. ``coding``) that an older pinned or fallback runtime predates. Degrade
to ``balanced`` rather than leaving the user with no proxy at all.
An unrecognized name degrades with a warning instead of raising. The savings
profile is a soft config knob, but it is resolved during proxy startup
(``proxy_pipeline_kwargs`` -> ``create_app``), so raising here takes the
whole proxy down before it can open its port. That happens on desktop/runtime
version skew: a newer client requests a profile (e.g. ``coding``) that an
older pinned or fallback runtime predates. Degrade rather than leaving the
user with no proxy at all.
**Where it degrades to matters.** This used to land on ``balanced``
unconditionally, which is a drastically different posture from the
out-of-box default: cache->token mode, cross-turn dedup off, tool-search
off, user messages uncompressed, the message floor 25x higher (250 vs 10)
and the block floor 20x higher (500 vs 25). A single typo in
``HEADROOM_SAVINGS_PROFILE`` therefore silently reconfigured the whole
proxy, and the only trace was one WARNING at startup that operators read
past. Prefer :data:`DEFAULT_PROFILE` the documented out-of-box posture and
the same thing an unset variable resolves to, so a typo now costs nothing.
``balanced`` remains the last resort for the genuine version-skew case,
where an older runtime has no ``DEFAULT_PROFILE`` entry to fall back to.
"""
key = (name or DEFAULT_PROFILE).strip().lower()
profile = _PROFILES.get(key)
if profile is not None:
return profile
fallback_name = DEFAULT_PROFILE if DEFAULT_PROFILE in _PROFILES else FALLBACK_PROFILE
valid = ", ".join(sorted(_PROFILES))
logger.warning(
"unknown savings profile %r; falling back to %r (known: %s)",
"unknown savings profile %r; falling back to %r (known: %s). "
"Set HEADROOM_SAVINGS_PROFILE to one of the known names, or unset it to "
"get %r explicitly.",
name,
FALLBACK_PROFILE,
fallback_name,
valid,
DEFAULT_PROFILE,
)
return _PROFILES[FALLBACK_PROFILE]
return _PROFILES[fallback_name]
def apply_agent_savings_env_defaults(
@ -300,6 +316,28 @@ def proxy_pipeline_kwargs(config: object) -> dict[str, object]:
# unset → Kompress decides / ambient default applies).
if profile.target_ratio is not None:
kwargs["target_ratio"] = profile.target_ratio
# Block-compression char floor. Every OTHER router pipeline kwarg in this
# function travels on the config object; this one alone was populated
# only from ``HEADROOM_MIN_CHARS_FOR_BLOCK`` (read below), so a proxy
# whose config carries ``savings_profile="coding"`` but whose process env
# was never seeded applied every sibling coding knob while this floor
# silently stayed at ``ContentRouterConfig.min_chars_for_block_compression``
# (500) instead of the profile's 25 — a 20x gap on the gate that governs
# tool_result blocks, the dominant content type in agent traffic.
#
# NOTE: this does not make the profile fully config-deliverable. The
# profile's ``cross_turn_dedup`` / ``tool_search`` / ``lossless_then_lossy``
# / ``protect_reads`` / ``code_aware`` / ``effort_router`` / ``lossless``
# fields are still env-only, but by a different mechanism: their consumers
# read ``os.environ`` directly (ContentRouter.__init__ for HEADROOM_DEDUPE,
# the Anthropic handler for HEADROOM_TOOL_SEARCH) and never pass through
# this function at all. Those remain seed-dependent and are the reason a
# profile can still be half-applied; fixing them means threading each
# consumer, which is a larger change than this one.
#
# The env read below still wins, since it is an explicit operator override.
if profile.min_chars_for_block is not None:
kwargs["min_chars_for_block_compression"] = profile.min_chars_for_block
if getattr(config, "compress_user_messages", False):
kwargs["compress_user_messages"] = True

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
@ -225,6 +226,8 @@ def _mirror_url(url: str) -> str:
mirror = os.environ.get("HEADROOM_BINARIES_MIRROR")
if not mirror:
return url
if not mirror.startswith("https://"):
raise BinaryFetchError(f"HEADROOM_BINARIES_MIRROR must use https:// (got {mirror!r})")
# Only substitute the github.com host so that paths remain intact.
for prefix in ("https://github.com", "https://objects.githubusercontent.com"):
if url.startswith(prefix):
@ -244,13 +247,27 @@ def _download(url: str, dest: Path, *, progress: bool = True) -> None:
if not _is_writable_dir(dest.parent):
raise OSError(f"binary cache directory is not writable: {dest.parent}")
final_url = _mirror_url(url)
if not final_url.startswith("https://"):
raise BinaryFetchError(f"refusing non-https download URL: {final_url!r}")
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:
@ -294,10 +311,16 @@ def _sha256_file(path: Path) -> str:
def _verify_sha256(path: Path, expected: str | None) -> None:
if os.environ.get("HEADROOM_BINARIES_ALLOW_UNVERIFIED"):
logger.warning(
"skipping sha256 verification for %s (HEADROOM_BINARIES_ALLOW_UNVERIFIED=1)",
path.name,
)
return
if not expected:
# Upstream release not SHA-pinned in registry. HTTPS + the GitHub CDN
# is the only integrity check. Log at INFO so verbose runs can see
# this state; `doctor` surfaces the same fact via `sha_pinned=False`.
# No pin in the registry (e.g. an off-registry version override). All
# shipped assets ARE pinned — enforced by the tools-hash-refresh CI gate —
# so a missing pin means an off-registry fetch; fall back to HTTPS trust.
logger.info("binary %s downloaded without sha256 pin (HTTPS trust only)", path.name)
return
got = _sha256_file(path)
@ -306,6 +329,40 @@ def _verify_sha256(path: Path, expected: str | None) -> None:
raise Sha256Mismatch(f"sha256 mismatch for {path.name}: expected {expected}, got {got}")
def sha256_for_url(url: str) -> str | None:
"""Return the registry's pinned sha256 for a download URL, if present."""
for tool in _registry().get("tools", {}).values():
for asset in tool.get("assets", {}).values():
if asset.get("url") == url:
pin = asset.get("sha256")
return pin if isinstance(pin, str) else None
return None
def verify_download_bytes(data: bytes, *, url: str, name: str) -> None:
"""Fail-closed integrity check for an in-memory downloaded archive.
Used by installers (rtk, lean-ctx, codebase-memory-mcp) that download and
extract on their own instead of going through the fetch path above. Verifies
the bytes against the tools.json pin for ``url`` and refuses an unpinned URL
unless HEADROOM_BINARIES_ALLOW_UNVERIFIED=1.
"""
if os.environ.get("HEADROOM_BINARIES_ALLOW_UNVERIFIED"):
logger.warning(
"skipping sha256 verification for %s (HEADROOM_BINARIES_ALLOW_UNVERIFIED=1)", name
)
return
expected = sha256_for_url(url)
if not expected:
# Off-registry URL (e.g. a version override); shipped assets are all
# pinned via the CI gate, so fall back to HTTPS trust here.
logger.info("%s downloaded without sha256 pin (HTTPS trust only)", name)
return
got = hashlib.sha256(data).hexdigest()
if got.lower() != expected.lower():
raise Sha256Mismatch(f"sha256 mismatch for {name}: expected {expected}, got {got}")
# ---------- Archive extraction ------------------------------------------- #

View file

@ -449,7 +449,7 @@ def overlay_cached_prefix(
previous_original_messages: list[dict[str, Any]] | None,
previous_forwarded_messages: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Replay the previously-forwarded (cached, compressed) prefix byte-identical.
"""Replay a positional, non-inflating cached prefix when it is safe.
Provider-agnostic cache-safety guard for the freeze path. When a message is
"frozen", the compression pipeline may emit the agent's ORIGINAL bytes for
@ -467,14 +467,23 @@ def overlay_cached_prefix(
``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
leaves not whether the frozen prefix busts the cache.
The optimized and current-original lists must be positionally aligned, and
compact UTF-8 JSON for the replayed result must not exceed the optimized
candidate. These bounds prefer a cache miss to corrupting or inflating a
client's live history.
"""
prev_orig = previous_original_messages
prev_fwd = previous_forwarded_messages
if not prev_orig or not prev_fwd:
return optimized_messages
if len(optimized_messages) != len(current_original_messages):
logger.debug(
"overlay: optimized/current-original length mismatch (optimized=%d, current=%d) "
"— skipping positional cached-prefix replay",
len(optimized_messages),
len(current_original_messages),
)
return optimized_messages
n = len(prev_orig)
# Positional 1:1 correspondence between prev_orig[i] and prev_fwd[i] holds
# only when last turn forwarded exactly one message per original (the
@ -534,11 +543,21 @@ def overlay_cached_prefix(
len(current_content) - split,
message_index,
)
return (
replayed = (
list(prev_fwd[:message_index])
+ [merged]
+ list(optimized_messages[message_index + 1 :])
)
replayed_bytes = _compact_json_bytes(replayed)
optimized_bytes = _compact_json_bytes(optimized_messages)
if (
replayed_bytes is None
or optimized_bytes is None
or len(replayed_bytes) > len(optimized_bytes)
):
logger.debug("overlay: block replay inflated compact JSON — skipping")
return optimized_messages
return replayed
# 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
@ -562,7 +581,7 @@ def overlay_cached_prefix(
# current_original[k] canonicalize-equals prev_orig[k], and prev_fwd[k]
# positionally corresponds to prev_orig[k] (guaranteed by the count check
# above), so no wrong bytes are ever forwarded.
limit = min(n, len(current_original_messages), len(optimized_messages))
limit = min(n, len(current_original_messages))
k = 0
while k < limit and _canonicalize_for_prefix_compare(
current_original_messages[k]
@ -584,7 +603,30 @@ def overlay_cached_prefix(
)
# Replay the cached (compressed) prefix byte-identical up to the first
# divergence; keep this turn's freshly-produced output for the rest.
return list(prev_fwd[:k]) + list(optimized_messages[k:])
replayed = list(prev_fwd[:k]) + list(optimized_messages[k:])
replayed_bytes = _compact_json_bytes(replayed)
optimized_bytes = _compact_json_bytes(optimized_messages)
if (
replayed_bytes is None
or optimized_bytes is None
or len(replayed_bytes) > len(optimized_bytes)
):
logger.debug("overlay: replay inflated compact JSON — skipping cached-prefix replay")
return optimized_messages
return replayed
def _compact_json_bytes(value: Any) -> bytes | None:
"""Return compact JSON bytes, or ``None`` when sizing cannot be proved."""
try:
return json.dumps(
value,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
except (TypeError, ValueError, OverflowError, UnicodeError):
return None
_STABLE_BOUNDARY_ENV = "HEADROOM_STABLE_BOUNDARY_BREAKPOINT"

View file

@ -571,11 +571,23 @@ class StreamingCCRBuffer:
chunks: list[bytes] = field(default_factory=list)
detected_ccr: bool = False
complete_response: dict[str, Any] | None = None
provider: str = "anthropic"
# Patterns to detect tool_use in stream
# Wire markers for the start of a tool call. Anthropic streams
# `"type":"tool_use"` content blocks; OpenAI-compatible streams carry a
# `"tool_calls"` array inside `choices[].delta` and never emit the
# Anthropic marker, so scanning only for the latter meant CCR was never
# detected on an OpenAI stream.
_tool_use_start: bytes = b'"type":"tool_use"'
_openai_tool_use_start: bytes = b'"tool_calls"'
_ccr_tool_pattern: bytes = f'"{CCR_TOOL_NAME}"'.encode()
def _tool_call_marker(self) -> bytes:
"""The provider's on-the-wire marker for the start of a tool call."""
if self.provider == "anthropic":
return self._tool_use_start
return self._openai_tool_use_start
def add_chunk(self, chunk: bytes) -> bool:
"""Add a chunk and check for CCR tool calls.
@ -587,7 +599,7 @@ class StreamingCCRBuffer:
# Quick check: does accumulated content contain CCR tool?
accumulated = b"".join(self.chunks)
if self._tool_use_start in accumulated and self._ccr_tool_pattern in accumulated:
if self._tool_call_marker() in accumulated and self._ccr_tool_pattern in accumulated:
self.detected_ccr = True
return True
@ -622,7 +634,7 @@ class StreamingCCRHandler:
) -> None:
self.response_handler = response_handler
self.provider = provider
self.buffer = StreamingCCRBuffer()
self.buffer = StreamingCCRBuffer(provider=provider)
async def process_stream(
self,
@ -648,8 +660,12 @@ class StreamingCCRHandler:
Response chunks (possibly from continuation response).
"""
# Phase 1: Initial detection
# Buffer chunks until we can determine if there's a CCR call
detection_complete = False
# Buffer chunks until we can determine if there's a CCR call.
#
# The end-of-stream marker is provider-specific. Anthropic signals the
# terminal state with `stop_reason` in `message_delta`; OpenAI-compatible
# streams have no such field and terminate with the `[DONE]` sentinel.
end_marker = b'"stop_reason"' if self.provider == "anthropic" else b"data: [DONE]"
async for chunk in stream_iterator:
self.buffer.add_chunk(chunk)
@ -660,9 +676,7 @@ class StreamingCCRHandler:
accumulated = self.buffer.get_accumulated()
# Look for stream end markers
if b'"stop_reason"' in accumulated:
detection_complete = True
if end_marker in accumulated:
if self.buffer.detected_ccr:
# CCR detected - need to handle
break
@ -679,13 +693,15 @@ class StreamingCCRHandler:
yield buffered_chunk
self.buffer.clear()
# Continue streaming rest of response
if not detection_complete and not self.buffer.detected_ccr:
async for chunk in stream_iterator:
if self.buffer.detected_ccr:
self.buffer.add_chunk(chunk)
else:
yield chunk
# The end marker is not guaranteed to arrive: upstream can truncate, a
# provider can omit the sentinel, or the stream can be a shape this
# detector does not recognise. Anything still buffered once the source
# iterator is exhausted is real response data the client has never
# seen, so flush it instead of dropping it.
if not self.buffer.detected_ccr and self.buffer.chunks:
for buffered_chunk in self.buffer.chunks:
yield buffered_chunk
self.buffer.clear()
# Phase 2: Handle CCR if detected
if self.buffer.detected_ccr:
@ -903,13 +919,38 @@ class StreamingCCRHandler:
}
tool_calls_map: dict[int, dict[str, Any]] = {}
finish_reason: str | None = None
envelope: dict[str, Any] = {}
usage: Any = None
for event in events:
choices = event.get("choices", [])
if not choices:
# Carry the chunk envelope through. Dropping it left the
# reconstructed body without `id`, `model`, `created` or `usage`,
# which downstream middleware reads for routing and metering.
for key in ("id", "created", "model", "system_fingerprint"):
value = event.get(key)
if value is not None:
envelope[key] = value
if event.get("usage") is not None:
usage = event["usage"]
choices = event.get("choices")
if not isinstance(choices, list) or not choices:
continue
choice = choices[0]
if not isinstance(choice, dict):
continue
delta = choices[0].get("delta", {})
# `finish_reason` is null on every chunk but the last, so keep the
# most recent non-null value rather than the first one seen.
if choice.get("finish_reason") is not None:
finish_reason = choice["finish_reason"]
# Some OpenAI-compatible providers send `"delta": null` on the
# terminal chunk instead of an empty object.
delta = choice.get("delta")
if not isinstance(delta, dict):
delta = {}
if "content" in delta and delta["content"]:
message["content"] = (message.get("content") or "") + delta["content"]
@ -944,14 +985,94 @@ class StreamingCCRHandler:
tc["function"]["arguments"] += fn["arguments"]
message["tool_calls"] = [tool_calls_map[i] for i in sorted(tool_calls_map.keys())]
if not message["tool_calls"]:
has_tool_calls = bool(message["tool_calls"])
if not has_tool_calls:
del message["tool_calls"]
if not message["content"]:
message["content"] = None
return {
"choices": [{"message": message, "finish_reason": "stop"}],
# OpenAI requires `finish_reason: "tool_calls"` whenever the message
# carries tool calls. This was hardcoded to "stop", which tells any
# client that drives its agent loop off `finish_reason` that the turn
# is over, so the reconstructed tool calls were never executed.
if has_tool_calls:
finish_reason = "tool_calls"
elif finish_reason is None:
finish_reason = "stop"
response: dict[str, Any] = {
"object": "chat.completion",
**envelope,
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
}
if usage is not None:
response["usage"] = usage
return response
def _openai_response_to_chunks(self, response: dict[str, Any]) -> list[bytes]:
"""Split a non-streaming ``chat.completion`` body into SSE chunk frames.
A streaming client reads ``choices[].delta``, not ``choices[].message``.
Serialising the reconstructed non-streaming body into a single SSE frame
produced a stream in which both the text and the tool calls were
invisible to the client.
"""
choices = response.get("choices")
choice = choices[0] if isinstance(choices, list) and choices else {}
if not isinstance(choice, dict):
choice = {}
message = choice.get("message")
if not isinstance(message, dict):
message = {}
finish_reason = choice.get("finish_reason") or "stop"
base: dict[str, Any] = {"object": "chat.completion.chunk"}
for key in ("id", "created", "model", "system_fingerprint"):
if response.get(key) is not None:
base[key] = response[key]
def frame(delta: dict[str, Any], reason: str | None) -> bytes:
payload = {
**base,
"choices": [{"index": 0, "delta": delta, "finish_reason": reason}],
}
return f"data: {json.dumps(payload)}\n\n".encode()
frames = [frame({"role": message.get("role") or "assistant"}, None)]
content = message.get("content")
if content:
frames.append(frame({"content": content}, None))
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list):
for index, tool_call in enumerate(tool_calls):
if not isinstance(tool_call, dict):
continue
function = tool_call.get("function")
if not isinstance(function, dict):
function = {}
frames.append(
frame(
{
"tool_calls": [
{
"index": index,
"id": tool_call.get("id", ""),
"type": tool_call.get("type", "function"),
"function": {
"name": function.get("name", ""),
"arguments": function.get("arguments", ""),
},
}
]
},
None,
)
)
frames.append(frame({}, finish_reason))
return frames
async def _response_to_sse(
self,
@ -968,6 +1089,7 @@ class StreamingCCRHandler:
for chunk in StreamingMixin()._response_to_sse(response, "anthropic"):
yield chunk
else:
# OpenAI SSE format
yield f"data: {json.dumps(response)}\n\n".encode()
# OpenAI SSE format: `chat.completion.chunk` frames, then [DONE].
for chunk in self._openai_response_to_chunks(response):
yield chunk
yield b"data: [DONE]\n\n"

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

@ -14,6 +14,7 @@ from __future__ import annotations
import json
import os
import re
import sys
from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass
from datetime import datetime
@ -30,6 +31,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,
@ -189,6 +192,84 @@ 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 claude_desktop_config_dir() -> Path:
"""Return Claude Desktop's per-user config directory for this platform.
Claude Desktop (``com.anthropic.claudefordesktop``) stores its config here,
distinct from Claude Code CLI's ``~/.claude``. Directory existence is used as
a proxy for "Desktop is installed / has been run" (#2925).
"""
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Application Support" / "Claude"
if os.name == "nt":
appdata = os.environ.get("APPDATA")
base = Path(appdata) if appdata else home / "AppData" / "Roaming"
return base / "Claude"
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else home / ".config"
return base / "Claude"
def check_claude_desktop(config_dir: Path) -> CheckResult | None:
"""Surface that Claude Desktop agent sessions bypass the proxy (#2925 / #869).
Claude Desktop unconditionally overwrites ``ANTHROPIC_BASE_URL`` when it
spawns agent sessions, so a correctly-wrapped ``~/.claude/settings.json``
(which the ``claude`` check verifies for the terminal CLI) does not route
Desktop traffic. Without this, ``doctor`` passes on the settings value alone
and never hints that Desktop sessions are unrouted.
Reported as its own per-surface row -- like ``wrap_marker`` and ``shell env``
-- and only when Desktop is detected, so it never contradicts a genuinely
routed CLI. Returns ``None`` when Desktop is absent (no row).
"""
if not config_dir.exists():
return None
return CheckResult(
name="claude desktop",
status=WARN,
summary="agent sessions bypass the proxy (Desktop overwrites ANTHROPIC_BASE_URL)",
hint=(
"Desktop routing is not supported yet (see #869); use the terminal "
"Claude Code CLI for proxy-routed sessions."
),
)
def check_claude_remote_control_gate(
settings_path: Path,
environ: Mapping[str, str],
@ -567,16 +648,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).
@ -585,6 +676,9 @@ def doctor(port: int, emit_json: bool) -> None:
)
if remote_control_gate_check is not None:
checks.append(remote_control_gate_check)
desktop_check = check_claude_desktop(claude_desktop_config_dir())
if desktop_check is not None:
checks.append(desktop_check)
deployments = check_deployments(list_manifests())
if deployments is not None:
checks.append(deployments)

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:
@ -435,9 +505,10 @@ def _echo_installed(manifest: DeploymentManifest, *, prefix: str = "Installed pe
"--port",
"-p",
default=8787,
envvar="HEADROOM_PORT",
type=click.IntRange(1, 65535),
show_default=True,
help="Persistent proxy port.",
help="Persistent proxy port (env: HEADROOM_PORT).",
)
@click.option(
"--backend",
@ -495,7 +566,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 +665,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)
@ -601,7 +683,13 @@ def install_apply(
@main.command("deploy")
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
@click.option(
"--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
"--port",
"-p",
default=8787,
envvar="HEADROOM_PORT",
type=int,
show_default=True,
help="Persistent proxy port (env: HEADROOM_PORT).",
)
@click.option(
"--backend",

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

@ -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,8 @@ import logging
import os
import sys
import warnings
from importlib import import_module
from pathlib import Path
from typing import Any, Literal, cast
import click
@ -18,6 +20,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.
#
@ -80,6 +114,55 @@ def _get_env_bool_optional(name: str) -> bool | None:
return _get_env_bool(name, False)
# libmalloc reads these before main() runs, so they cannot be set from inside
# the current process — the proxy re-execs itself once to apply them. Without
# them, freed pages from large concurrent request bodies stay resident
# (``vmmap`` shows whole "MALLOC_LARGE (empty)" regions) and long-lived proxy
# RSS only ratchets upward (#2820). Vars the operator already set are left
# untouched; HEADROOM_MALLOC_TUNING=0 disables the re-exec entirely.
_MALLOC_TUNING = {
"MallocAggressiveMadvise": "1", # madvise freed pages back to the OS eagerly
"MallocLargeCache": "0", # no death-row cache for freed large allocations
}
def _process_is_headroom_cli_entrypoint() -> bool:
"""Is this process the Headroom CLI itself, rather than an embedder?
``_reexec_with_malloc_tuning`` rebuilds the command line as
``python -m headroom.cli <argv[1:]>``. That is only a faithful
reconstruction when the process really was started as the Headroom CLI. If
something else invoked the ``proxy`` command in-process pytest's
``CliRunner``, an embedding application, ``runpy`` then ``argv[1:]``
belongs to *that* program, and ``os.execv`` would replace it with a Headroom
process parsing arguments that were never meant for us.
"""
argv0 = Path(sys.argv[0] or "")
if argv0.name in {"headroom", "headroom.exe"}:
return True
# `python -m headroom.cli` sets argv[0] to .../headroom/cli/__main__.py.
return argv0.parts[-3:] == ("headroom", "cli", "__main__.py")
def _reexec_with_malloc_tuning() -> None:
if sys.platform != "darwin":
return
if not _get_env_bool("HEADROOM_MALLOC_TUNING", True):
return
if os.environ.get("_HEADROOM_MALLOC_TUNED") == "1":
return
if not _process_is_headroom_cli_entrypoint():
return
missing = {k: v for k, v in _MALLOC_TUNING.items() if k not in os.environ}
# Set the loop guard before the re-exec so the replacement process (which
# inherits this environment) skips this path instead of re-execing forever.
os.environ["_HEADROOM_MALLOC_TUNED"] = "1"
if not missing:
return
os.environ.update(missing)
os.execv(sys.executable, [sys.executable, "-m", "headroom.cli", *sys.argv[1:]])
def _get_env_int_optional(name: str) -> int | None:
val = os.environ.get(name)
if val is None or val == "":
@ -258,7 +341,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)")
@ -641,7 +724,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(
@ -1031,23 +1115,17 @@ def proxy(
Usage with OpenAI-compatible clients:
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
"""
_reexec_with_malloc_tuning()
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:
@ -1080,12 +1158,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()
@ -1103,7 +1215,6 @@ def proxy(
err=True,
)
sys.exit(1)
os.environ["HEADROOM_INTERCEPT_ENABLED"] = "1"
try:
resolved_anthropic_extra_headers = resolve_extra_headers(
@ -1185,6 +1296,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,
@ -1200,6 +1312,10 @@ def proxy(
rate_limit_requests_per_minute=rpm if rpm is not None else 60,
rate_limit_tokens_per_minute=tpm if tpm is not None else 100_000,
compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False),
periodic_malloc_trim_enabled=_get_env_bool(
"HEADROOM_MALLOC_TRIM", sys.platform == "darwin"
),
malloc_trim_interval_seconds=_get_env_int("HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS", 60),
min_tokens_to_crush=_get_env_int("HEADROOM_MIN_TOKENS", 500),
max_items_after_crush=_get_env_int("HEADROOM_MAX_ITEMS", 50),
exclude_tools=_parse_exclude_tools(None) or None,
@ -1214,12 +1330,22 @@ def proxy(
protect_recent=_get_env_int_optional("HEADROOM_PROTECT_RECENT"),
protect_analysis_context=_get_env_bool_optional("HEADROOM_PROTECT_ANALYSIS_CONTEXT"),
accuracy_guard=os.environ.get("HEADROOM_ACCURACY_GUARD") or None,
# CCR opt-out: --no-ccr disables both halves at once (markers in content
# AND the injected retrieve tool). Markers without a tool — or a tool
# without markers — are useless, so it is a single switch. Default keeps
# CCR fully on.
# CCR opt-out: --no-ccr disables every half at once — markers in
# content, the injected retrieve tool, AND server-side response
# handling. Markers without a tool, or a tool without markers, are
# useless, so it is a single switch. Default keeps CCR fully on.
#
# Response handling has to be part of it. The buffered stream:false
# path keys off ``headroom_retrieve`` being present in the *request's*
# tools, and a client can advertise that tool on its own — the bundled
# OpenCode plugin registers it unconditionally. So with response
# handling left on, `--no-ccr` silently kept flipping streaming turns
# to buffered whenever history still held a redeemable marker, and the
# documented escape hatch for the CCR buffered-stream bugs did nothing
# for exactly the clients told to use it (#3082).
ccr_inject_tool=not no_ccr,
ccr_inject_marker=not no_ccr,
ccr_handle_responses=not no_ccr,
ccr_resolve_markers_inline=ccr_inline_resolve,
lossless=lossless,
ccr_proactive_expansion=not no_ccr_proactive_expansion,
@ -1291,7 +1417,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

@ -59,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,
@ -76,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,
@ -133,7 +136,12 @@ from headroom.providers.copilot import (
validate_configuration as _validate_copilot_configuration,
)
from headroom.providers.cursor import render_setup_lines as _render_cursor_setup_lines
from headroom.providers.grok import build_launch_env as _build_grok_launch_env
from headroom.providers.grok import (
DEFAULT_API_URL as _GROK_DEFAULT_API_URL,
)
from headroom.providers.grok import (
build_launch_env as _build_grok_launch_env,
)
from headroom.providers.grok_build import render_setup_lines as _render_grok_build_setup_lines
from headroom.providers.grok_build.config import (
inject_grok_provider_config,
@ -259,6 +267,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)
@ -289,9 +321,13 @@ _AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build"}
# so `--1m` forces the suffix via ANTHROPIC_MODEL on the launched process.
_ANTHROPIC_MODEL_ENV = "ANTHROPIC_MODEL"
_CONTEXT_1M_SUFFIX = "[1m]"
# Only used when no model is otherwise selected (no ANTHROPIC_MODEL set). The
# current default Opus; the suffix logic preserves any model the user did set.
_DEFAULT_1M_MODEL = "claude-opus-4-8"
_1M_MODEL_ENV = "HEADROOM_1M_MODEL"
# Fallback model for `--1m` when nothing else selects one (no ANTHROPIC_MODEL,
# no explicit --model). Overridable via HEADROOM_1M_MODEL so it can track new
# Opus releases without a code change and without pinning ANTHROPIC_MODEL
# globally (which would also change non-`--1m` sessions and override Claude
# Code's /model picker). #2937.
_DEFAULT_1M_MODEL = "claude-opus-5"
_OPENCLAUDE_INSTRUCTIONS_FILE = "CONVENTIONS.md"
@ -299,11 +335,12 @@ def _resolve_1m_model(current: str | None) -> str:
"""Return the model id that makes Claude Code request the 1M window (#1158).
Preserves a model the user already selected via ``ANTHROPIC_MODEL`` (only
appending the ``[1m]`` suffix when missing); falls back to the default Opus
when none is set. Idempotent a value already ending in ``[1m]`` is
returned unchanged.
appending the ``[1m]`` suffix when missing). When none is set it falls back
to ``HEADROOM_1M_MODEL`` if defined, else the built-in default Opus (#2937).
Idempotent a value already ending in ``[1m]`` is returned unchanged.
"""
base = (current or "").strip() or _DEFAULT_1M_MODEL
fallback = (os.environ.get(_1M_MODEL_ENV) or "").strip() or _DEFAULT_1M_MODEL
base = (current or "").strip() or fallback
return base if base.endswith(_CONTEXT_1M_SUFFIX) else f"{base}{_CONTEXT_1M_SUFFIX}"
@ -450,6 +487,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, "")
@ -794,7 +833,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."
)
@ -854,8 +893,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
@ -1475,6 +1516,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,
*,
@ -1715,6 +1786,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()
@ -1725,24 +1808,108 @@ 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
@ -1753,20 +1920,32 @@ def _index_serena_project(*, verbose: bool = False) -> None:
"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(
@ -1833,7 +2012,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
@ -4590,6 +4771,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]
@ -4598,6 +4781,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)
@ -4821,6 +5010,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
@ -4866,6 +5060,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],
@ -5322,8 +5521,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()
@ -5343,7 +5542,9 @@ def vscode_copilot(
click.echo(
f' "github.copilot.advanced.debug.overrideProxyUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
)
click.echo(' "github.copilot.advanced.debug.overrideAuthType": "token"')
click.echo(
f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}"'
)
_run_proxy_only_watcher(
agent_label="VS CODE COPILOT",
@ -5607,6 +5808,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,
@ -6222,7 +6426,7 @@ def grok(
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
openai_api_url="https://api.x.ai",
openai_api_url=_GROK_DEFAULT_API_URL,
)
@ -6312,9 +6516,9 @@ def grok_build(
\b
Grok Build reads model endpoints from ``~/.grok/config.toml``. This
command starts the proxy, optionally sets up the selected CLI context
tool, injects a Headroom-managed ``[model.grok-build]`` override, and
prints next steps.
command starts the proxy (upstream ``https://api.x.ai``, same as
``wrap grok``), injects a Headroom-managed ``[model.grok-build]``
override, and prints next steps.
\b
Example:
@ -6341,6 +6545,8 @@ def grok_build(
for line in _render_grok_build_setup_lines(actual_port, project=project):
click.echo(line)
# Client hop is local proxy via config.toml; upstream must be xAI (not
# the OpenAI default). Omitting this caused 401s with Grok auth headers.
_run_proxy_only_watcher(
agent_label="grok-build",
port=port,
@ -6349,6 +6555,7 @@ def grok_build(
memory=memory,
agent_type="grok_build",
print_setup_lines=_print_grok_build_setup,
openai_api_url=_GROK_DEFAULT_API_URL,
)

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

@ -28,6 +28,17 @@ from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_mac
logger = logging.getLogger(__name__)
DEFAULT_API_URL = "https://api.githubcopilot.com"
# Copilot serves *chat* from the CAPI host above and *inline completions* from a
# separate proxy host. GitHub's own client library keeps them apart:
#
# _getCAPIUrl(t) -> t?.endpoints.api || "https://api.githubcopilot.com"
# _getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL
# DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com"
#
# and builds completions as `${proxyBaseURL}/v1/engines/<engine>/completions`
# (@vscode/copilot-api 0.5.2). Sending that path to the CAPI host is the wrong
# surface, so the completions default has to be its own constant (#3076).
DEFAULT_COMPLETIONS_PROXY_URL = "https://copilot-proxy.githubusercontent.com"
DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
DEFAULT_USER_INFO_URL = "https://api.github.com/copilot_internal/user"
DEFAULT_GITHUB_HOST = "github.com"
@ -245,6 +256,120 @@ def _configured_api_url() -> str:
return DEFAULT_API_URL
def copilot_api_url() -> str:
"""Return the configured Copilot API base URL without any network calls.
Resolves ``GITHUB_COPILOT_API_URL``, then the configured enterprise domain,
then ``api.githubcopilot.com``. Unlike :func:`resolve_copilot_api_url` this
performs no token exchange, so it is safe to call while routing a request.
"""
return _configured_api_url()
# GitHub's token exchange advertises the host that serves inline completions
# under ``endpoints.proxy``, alongside the ``endpoints.api`` chat host. It is
# recorded here when observed so completions routing uses GitHub's own answer
# instead of an assumption about which host serves that endpoint (#3076).
_observed_completions_base_url: str | None = None
def _remember_completions_endpoint(payload: Any) -> None:
"""Record the completions host advertised by a token-exchange payload."""
global _observed_completions_base_url
endpoints = payload.get("endpoints") if isinstance(payload, dict) else None
proxy_url = endpoints.get("proxy") if isinstance(endpoints, dict) else None
if isinstance(proxy_url, str) and proxy_url.strip():
_observed_completions_base_url = proxy_url.strip().rstrip("/")
def reset_observed_completions_endpoint() -> None:
"""Forget the advertised completions host (test isolation)."""
global _observed_completions_base_url
_observed_completions_base_url = None
def _url_host(value: str) -> str:
"""Hostname for a URL, tolerating a scheme-less value.
Mirrors the normalization :func:`is_copilot_api_url` performs, so a host
configured without "https://" is not silently treated as a different host.
"""
parsed = urlparse(value)
netloc_or_path = parsed.netloc.lower() or parsed.path.lower()
return (parsed.hostname or netloc_or_path.split("/", 1)[0]).lower()
def is_copilot_completions_host(url: str | None) -> bool:
"""Return True when *url* already points at a Copilot inline-completions host.
Distinct from :func:`is_copilot_api_url`, which matches the CAPI (chat)
surface. A CAPI host is *not* a completions host, so the two must not be
conflated when deciding whether a completions request is already addressed
correctly.
"""
if not url:
return False
# Compare hosts, never whole strings: this is asked both about a bare base
# URL (routing) and about a fully-built URL with the path appended (auth).
# A string compare answers True for the first and False for the second, so
# an operator override would route correctly and then be forwarded with no
# credentials at all.
host = _url_host(url)
if not host:
return False
override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip()
if override and host == _url_host(override):
return True
if host == "copilot-proxy.githubusercontent.com":
return True
# Per-SKU hosts GitHub hands out via `endpoints.proxy`, e.g.
# proxy.individual.githubcopilot.com / proxy.business… / proxy.enterprise….
return host.startswith("proxy.") and host.endswith(".githubcopilot.com")
def copilot_completions_base_url() -> str:
"""Return the base URL serving Copilot's inline-completions endpoint.
Resolution order, most authoritative first:
1. ``GITHUB_COPILOT_PROXY_URL`` an explicit operator override, so a
network that fronts Copilot behind its own gateway (or a GitHub change
to this endpoint) is a config edit rather than a code change.
2. ``endpoints.proxy`` from the last Copilot token exchange GitHub
telling us directly where completions go.
3. ``copilot-proxy.githubusercontent.com`` GitHub's own documented
default for this endpoint (see ``DEFAULT_COMPLETIONS_PROXY_URL``).
4. For an enterprise or otherwise custom Copilot deployment, that
deployment's own host. Falling back to the public GitHub host there would
send an enterprise tenant's keystrokes outside their deployment, which is
worse than failing to resolve.
Note what step 4 must *not* capture: a configured API URL that is itself a
public Copilot host. ``headroom wrap vscode`` sets ``GITHUB_COPILOT_API_URL``
to the resolved subscription URL (e.g. ``api.business.githubcopilot.com``),
which is the chat surface returning it here would put the completions path
straight back on the host that answers it with 404. Only a host outside
``*.githubcopilot.com`` indicates a deployment whose traffic has to stay put.
Never performs I/O; step 2 only reads what a previous exchange recorded.
"""
override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip()
if override:
return override.rstrip("/")
if _observed_completions_base_url:
return _observed_completions_base_url
configured = _configured_api_url_override()
if configured and not _is_public_copilot_api_host(_url_host(configured)):
return configured
return DEFAULT_COMPLETIONS_PROXY_URL
def _github_oauth_domain(domain: str | None = None) -> str:
raw = (domain or DEFAULT_GITHUB_HOST).strip()
if not raw:
@ -993,6 +1118,25 @@ def is_copilot_api_url(url: str | None) -> bool:
return _is_public_copilot_api_host(hostname) or _is_ghe_copilot_api_host(hostname)
def is_copilot_upstream_url(url: str | None) -> bool:
"""Return True for any Copilot-served upstream: chat (CAPI) or completions.
Copilot has two surfaces on two different hosts, and code that asks "is this
request going to Copilot?" means the union. :func:`is_copilot_api_url` alone
answers only for chat, so the completions host looked like a stranger:
``apply_copilot_api_auth`` attached no credentials to it (401) and
``build_copilot_upstream_url`` skipped ``mark_request_routed_to_copilot``,
which mislabels the provider in telemetry.
Deliberately *not* folded into :func:`is_copilot_api_url`, which also gates
validation of the ``endpoints.api`` value from a token exchange and the
Responses-API preference check neither of which should treat a completions
host as a chat host (#3076).
"""
return is_copilot_api_url(url) or is_copilot_completions_host(url)
def _is_public_copilot_api_host(host: str) -> bool:
"""Return True for GitHub-hosted Copilot API domains."""
@ -1056,12 +1200,35 @@ def reset_request_routed_to_copilot() -> None:
_request_routed_to_copilot.set(False)
def is_copilot_completions_path(path: str) -> bool:
"""Return True for Copilot's inline-completions ("ghost text") endpoint.
The Copilot editor extensions send code completions to
``/v1/engines/<engine>/completions`` on whatever host
``github.copilot.advanced.debug.overrideProxyUrl`` names so when that
setting points at Headroom, this is the path that arrives.
The shape identifies GitHub Copilot on its own. OpenAI's Engines API was
removed years ago and no other provider Headroom fronts serves it, so a
request on this path is Copilot's and can never be answered by the default
OpenAI target (#3076).
"""
normalized = (path if path.startswith("/") else f"/{path}").rstrip("/")
prefix = "/v1/engines/"
suffix = "/completions"
if not normalized.startswith(prefix) or not normalized.endswith(suffix):
return False
engine = normalized[len(prefix) : -len(suffix)]
return bool(engine) and "/" not in engine
def build_copilot_upstream_url(base_url: str, path: str) -> str:
"""Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
normalized_base = base_url.rstrip("/")
normalized_path = path if path.startswith("/") else f"/{path}"
if is_copilot_api_url(normalized_base):
if is_copilot_upstream_url(normalized_base):
# Single routing chokepoint for every Copilot surface (OpenAI
# chat/responses and Anthropic messages all build their upstream URL
# here), so mark the request for provider relabeling downstream.
@ -1071,7 +1238,17 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str:
# Anthropic surface for Claude models IS ``/v1/messages`` (with the
# ``/v1``); stripping it forwarded ``/messages`` and Copilot returned 404
# for claude-* models (#2409). Keep ``/v1`` for the messages endpoint.
if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"):
#
# Inline completions are the same story: the Copilot extension itself
# builds ``/v1/engines/<engine>/completions``, so the path that reaches
# us is already the exact path Copilot serves. Stripping ``/v1`` there
# rewrites a Copilot-native path into one that 404s (#3076). The rule
# this encodes: strip only for clients speaking generic-OpenAI at
# Copilot, never for Copilot's own paths.
keep_v1 = normalized_path.startswith("/v1/messages") or is_copilot_completions_path(
normalized_path
)
if normalized_path.startswith("/v1/") and not keep_v1:
normalized_path = normalized_path[3:]
else:
reset_request_routed_to_copilot()
@ -1207,7 +1384,12 @@ class CopilotTokenProvider:
try:
with urllib_request.urlopen(request, timeout=10.0) as response:
payload = json.loads(response.read().decode("utf-8"))
return payload if isinstance(payload, dict) else {}
if not isinstance(payload, dict):
return {}
# Every exchange funnels through here, so this is the one place
# that sees GitHub's advertised completions host (#3076).
_remember_completions_endpoint(payload)
return payload
except urllib_error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(
@ -1314,7 +1496,11 @@ def _is_managed_copilot_seeded_bearer(token: str) -> bool:
async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[str, str]:
"""Apply Copilot auth headers for GitHub Copilot API requests."""
resolved = dict(headers)
if not is_copilot_api_url(url):
# Both Copilot surfaces need credentials. Gating on the chat host alone left
# inline completions unauthenticated: the request reached
# copilot-proxy.githubusercontent.com with no Authorization header, and that
# host answers 401 (#3076).
if not is_copilot_upstream_url(url):
return resolved
for name, value in _copilot_chat_header_defaults().items():

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>
@ -847,7 +850,7 @@
<span class="px-2 py-0.5 bg-border rounded text-xs" x-text="truncateModel(model)"></span>
</td>
<td class="px-4 py-3 text-right font-mono tabular-nums" x-text="info.requests"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums text-accent" x-text="formatNumber(info.tokens_saved)"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums text-accent" x-text="formatNumber(info.tokens_saved)" :title="formatNumber(info.compression_tokens_saved || 0) + ' from compression · ' + formatNumber(info.tool_tokens_saved || 0) + ' from deferred tool schemas'"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(info.tokens_sent)"></td>
<td class="px-4 py-3 text-right">
<span class="text-accent font-mono tabular-nums" x-text="info.reduction_pct.toFixed(1) + '%'"></span>
@ -934,6 +937,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">

View file

@ -77,13 +77,17 @@ def download_cbm(version: str | None = None) -> Path:
except Exception as e:
raise RuntimeError(f"Failed to download codebase-memory-mcp from {url}: {e}") from e
from headroom.binaries import verify_download_bytes
verify_download_bytes(data, url=url, name="codebase-memory-mcp")
# Extract binary from tar.gz
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
for member in tar.getmembers():
if member.name.endswith(CBM_BIN_NAME) or member.name == CBM_BIN_NAME:
member.name = target_path.name
tar.extract(member, CBM_BIN_DIR)
tar.extract(member, CBM_BIN_DIR, filter="data")
break
else:
raise RuntimeError("codebase-memory-mcp binary not found in archive")

View file

@ -3,12 +3,15 @@
from __future__ import annotations
import shutil
import sys
from collections.abc import Iterable
import click
from headroom import paths as _paths
from headroom.providers.grok.runtime import DEFAULT_API_URL as _GROK_DEFAULT_API_URL
from headroom.providers.install_registry import build_install_target_envs
from headroom.rollout import RolloutChannel
from .models import (
ConfigScope,
@ -141,9 +144,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
@ -165,10 +178,43 @@ def build_manifest(
base_env["HEADROOM_TELEMETRY"] = "on" if telemetry_enabled else "off"
if memory_enabled:
base_env["HEADROOM_MEMORY_ENABLED"] = "1"
# Grok / Grok Build need proxy upstream = xAI. Only auto-set when no other
# OpenAI-compatible tools share this proxy (those may need api.openai.com /
# Copilot). Explicit OPENAI_TARGET_API_URL in extra_env still wins below.
_openai_native = {
ToolTarget.CODEX.value,
ToolTarget.COPILOT.value,
ToolTarget.AIDER.value,
ToolTarget.OPENCODE.value,
}
_grok_targets = {ToolTarget.GROK.value, ToolTarget.GROK_BUILD.value}
target_set = set(resolved_targets)
if target_set & _grok_targets and not (target_set & _openai_native):
base_env.setdefault("OPENAI_TARGET_API_URL", _GROK_DEFAULT_API_URL)
# Applied last so explicit --env overrides win over the auto-derived
# 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",
@ -209,11 +255,14 @@ def build_manifest(
proxy_args.extend(["--protect-tool-results", protect_tool_results])
if bedrock_profile:
proxy_args.extend(["--bedrock-profile", bedrock_profile])
openai_target = base_env.get("OPENAI_TARGET_API_URL")
if openai_target:
proxy_args.extend(["--openai-api-url", openai_target])
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

@ -539,6 +539,40 @@ def _strip_fenced_json(raw: str) -> dict:
return result
def _failure_detail(
stderr: str | None, stdout: str | None, *, result_text: str | None = None
) -> str:
"""Build the operator-facing reason for a non-zero CLI exit.
stderr alone is not enough. `claude -p --output-format stream-json` writes
*nothing* to stderr and reports API failures only in its final ``result``
event on stdout, so a stderr-only message renders as a bare
``failed (exit 1):`` with no reason at all -- the user (and we) cannot tell a
usage limit from an unreachable proxy from an expired login.
Both streams are included when both have content, and stdout is tailed rather
than headed because CLI backends emit the error last (a streaming backend's
whole event log precedes it).
Args:
stderr: Captured stderr, if any.
stdout: Captured stdout, if any.
result_text: Pre-extracted reason (claude-cli's final ``result`` field),
used in place of the raw stdout tail when available.
Returns:
A non-empty snippet, or ``"(no output captured)"`` when both streams were
empty, so the message is never a dangling colon.
"""
parts: list[str] = []
if stderr and stderr.strip():
parts.append(stderr.strip()[:_MAX_SNIPPET_LEN])
tail = result_text if result_text and result_text.strip() else stdout
if tail and tail.strip():
parts.append(tail.strip()[-_MAX_SNIPPET_LEN:])
return "\n".join(parts) if parts else "(no output captured)"
def _call_cli_llm(digest: str, model: str) -> dict:
"""Call a locally installed CLI tool as the LLM backend.
@ -611,10 +645,8 @@ def _call_cli_llm(digest: str, model: str) -> dict:
) from None
if result.returncode != 0:
stderr_snippet = (result.stderr or "")[:_MAX_SNIPPET_LEN]
raise RuntimeError(
f"`{' '.join(cmd)}` failed (exit {result.returncode}):\n{stderr_snippet}"
)
detail = _failure_detail(result.stderr, result.stdout)
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {result.returncode}):\n{detail}")
# Log stderr warnings even on success (auth refreshes, deprecation notices).
if result.stderr and result.stderr.strip():
@ -757,8 +789,14 @@ def _call_claude_cli_streaming(
proc.wait()
if proc.returncode != 0:
stderr_blob = "".join(stderr_lines)[:_MAX_SNIPPET_LEN]
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode}):\n{stderr_blob}")
# `final_result` is preferred over the raw stdout tail: claude emits a
# final `result` event even when the run fails, and its `result` field is
# the human-readable reason ("API Error: ...", "Not logged in", usage
# limits).
detail = _failure_detail(
"".join(stderr_lines), "".join(stdout_lines), result_text=final_result
)
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode}):\n{detail}")
stderr_blob = "".join(stderr_lines)
if stderr_blob.strip():

View file

@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
import numpy as np
from ..models import Memory, ScopeLevel
from ..models import Memory, ScopeLevel, normalize_entity_refs
from ..ports import VectorFilter, VectorSearchResult
# hnswlib is optional - may not compile on all platforms
@ -139,7 +139,9 @@ class IndexedMemoryMetadata:
valid_until=(
datetime.fromisoformat(data["valid_until"]) if data.get("valid_until") else None
),
entity_refs=data.get("entity_refs", []),
# Normalized on load so rows written before #2947 was fixed heal
# themselves instead of crashing search.
entity_refs=normalize_entity_refs(data.get("entity_refs")),
content=data["content"],
created_at=datetime.fromisoformat(data["created_at"]),
importance=data.get("importance", 0.5),

View file

@ -16,7 +16,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ..models import Memory, ScopeLevel
from ..models import Memory, ScopeLevel, normalize_entity_refs
from ..ports import MemoryFilter
if TYPE_CHECKING:
@ -227,7 +227,11 @@ class SQLiteMemoryStore:
last_accessed=datetime.fromisoformat(row["last_accessed"])
if row["last_accessed"]
else None,
entity_refs=json.loads(row["entity_refs"]) if row["entity_refs"] else [],
# Normalized on load so rows written before #2947 was fixed heal
# themselves instead of crashing search.
entity_refs=normalize_entity_refs(
json.loads(row["entity_refs"]) if row["entity_refs"] else []
),
embedding=self._deserialize_embedding(row["embedding"]),
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
)

View file

@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Any, cast
import numpy as np
from ..models import Memory, ScopeLevel
from ..models import Memory, ScopeLevel, normalize_entity_refs
from ..ports import VectorFilter, VectorSearchResult
if TYPE_CHECKING:
@ -135,7 +135,9 @@ class VectorMetadata:
valid_until=(
datetime.fromisoformat(d["valid_until"]) if d.get("valid_until") else None
),
entity_refs=d.get("entity_refs", []),
# Normalized on load so rows written before #2947 was fixed heal
# themselves instead of crashing search.
entity_refs=normalize_entity_refs(d.get("entity_refs")),
content=d["content"],
created_at=datetime.fromisoformat(d["created_at"]),
importance=d.get("importance", 0.5),

View file

@ -51,6 +51,7 @@ import asyncio
import hashlib
import inspect
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
@ -100,7 +101,7 @@ class Mem0Config:
# Neo4j settings
neo4j_uri: str = "neo4j://localhost:7687"
neo4j_user: str = "neo4j"
neo4j_password: str = "password"
neo4j_password: str = field(default_factory=lambda: os.environ.get("NEO4J_PASSWORD", ""))
# Qdrant settings (defaults resolve from HEADROOM_QDRANT_* env vars)
qdrant_url: str | None = field(default_factory=qdrant_env.qdrant_env_url)

View file

@ -21,7 +21,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from headroom.memory.adapters.graph_models import Entity, Relationship, Subgraph
from headroom.memory.models import Memory
from headroom.memory.models import Memory, normalize_entity_refs
from headroom.memory.ports import MemorySearchResult
from headroom.models.config import ML_MODEL_DEFAULTS
@ -284,8 +284,15 @@ class LocalBackend:
# Determine if using pre-extraction mode
has_pre_extraction = bool(facts or extracted_entities or extracted_relationships)
# Merge entity names from both simple and typed formats
all_entity_names: list[str] = list(entities) if entities else []
# Merge entity names from both simple and typed formats.
#
# `entities` is typed list[str], but it is populated straight from
# LLM-supplied memory_save tool arguments, and callers do sometimes
# pass the typed {"entity": ..., "entity_type": ...} shape here (that
# is what extracted_entities is for). Normalizing keeps those dicts out
# of entity_refs, where they used to crash every later search that
# retrieved the row -- see issue #2947.
all_entity_names: list[str] = normalize_entity_refs(entities)
entity_types: dict[str, str] = {}
if extracted_entities:
@ -448,13 +455,17 @@ class LocalBackend:
continue
seen_memory_ids.add(vr.memory.id)
all_entity_refs.update(vr.memory.entity_refs)
# Defense in depth: the storage adapters normalize entity_refs on
# load, but a backend that hands us Memory objects some other way
# must not be able to abort the whole search with one bad row.
entity_refs = normalize_entity_refs(vr.memory.entity_refs)
all_entity_refs.update(entity_refs)
results.append(
MemorySearchResult(
memory=vr.memory,
score=vr.similarity,
related_entities=list(vr.memory.entity_refs),
related_entities=entity_refs,
related_memories=[],
)
)
@ -503,15 +514,17 @@ class LocalBackend:
MemorySearchResult(
memory=memory,
score=0.5, # Default score for graph-expanded results
related_entities=list(memory.entity_refs),
related_entities=normalize_entity_refs(memory.entity_refs),
related_memories=[],
)
)
seen_memory_ids.add(mem_id)
# Filter by specified entities if provided
# Filter by specified entities if provided. Like the save path, this
# argument arrives from LLM-supplied tool input, so it gets the same
# normalization rather than trusting its list[str] annotation.
if entities:
entities_lower = {e.lower() for e in entities}
entities_lower = {e.lower() for e in normalize_entity_refs(entities)}
results = [
r
for r in results
@ -826,7 +839,7 @@ class LocalBackend:
MemorySearchResult(
memory=tr.memory,
score=tr.score,
related_entities=list(tr.memory.entity_refs),
related_entities=normalize_entity_refs(tr.memory.entity_refs),
related_memories=[],
)
for tr in text_results

View file

@ -12,6 +12,7 @@ Supports both local mode (embedded services) and cloud mode (Mem0 API).
from __future__ import annotations
import asyncio
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
@ -57,7 +58,7 @@ class Mem0Config:
# Local mode settings - Neo4j and Qdrant config
neo4j_uri: str = "neo4j://localhost:7687"
neo4j_user: str = "neo4j"
neo4j_password: str = "password"
neo4j_password: str = field(default_factory=lambda: os.environ.get("NEO4J_PASSWORD", ""))
# Qdrant settings (defaults resolve from HEADROOM_QDRANT_* env vars)
qdrant_url: str | None = field(default_factory=qdrant_env.qdrant_env_url)
qdrant_host: str = field(default_factory=qdrant_env.qdrant_env_host)

View file

@ -33,6 +33,7 @@ Backends:
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
@ -115,7 +116,7 @@ class Memory:
qdrant_api_key: str | None = None,
neo4j_uri: str = "neo4j://localhost:7687",
neo4j_user: str = "neo4j",
neo4j_password: str = "password",
neo4j_password: str | None = None,
) -> None:
from headroom.memory import qdrant_env
@ -145,7 +146,9 @@ class Memory:
)
self._neo4j_uri = neo4j_uri
self._neo4j_user = neo4j_user
self._neo4j_password = neo4j_password
self._neo4j_password = (
neo4j_password if neo4j_password is not None else os.environ.get("NEO4J_PASSWORD", "")
)
async def _ensure_initialized(self) -> None:
"""Initialize the backend on first use."""

View file

@ -14,6 +14,46 @@ except ImportError:
np = None # type: ignore[assignment]
def normalize_entity_refs(values: Any) -> list[str]:
"""Coerce a raw entity-reference list into the plain ``list[str]`` it claims to be.
``entity_refs`` (and the ``entities`` argument that feeds it) is typed
``list[str]``, but nothing enforced that at runtime, so callers have
persisted the typed ``{"entity": ..., "entity_type": ...}`` shape -- the
format ``extracted_entities`` expects -- into it by mistake. Those dicts
then break every consumer that treats a ref as a string: ``set().update()``
raises ``TypeError: unhashable type: 'dict'`` and ``ref.lower()`` raises
``AttributeError``, which took down whole memory searches rather than the
one bad row (see issue #2947).
Dicts are unwrapped to their ``entity`` name so no information is lost;
anything with no recoverable name is dropped rather than stringified, since
a ref like ``"{'entity_type': 'project'}"`` would only pollute the graph.
Order is preserved and duplicate names are collapsed.
"""
if not values:
return []
normalized: list[str] = []
seen: set[str] = set()
for value in values:
if isinstance(value, str):
name = value
elif isinstance(value, dict):
# The extracted_entities shape, mistakenly used as a plain name.
candidate = value.get("entity") or value.get("name")
name = candidate if isinstance(candidate, str) else ""
else:
name = ""
if name and name not in seen:
seen.add(name)
normalized.append(name)
return normalized
class ScopeLevel(Enum):
"""Memory scope hierarchy levels."""
@ -132,7 +172,9 @@ class Memory:
last_accessed=datetime.fromisoformat(data["last_accessed"])
if data.get("last_accessed")
else None,
entity_refs=data.get("entity_refs", []),
# Normalized on load so rows written before #2947 was fixed heal
# themselves instead of crashing search.
entity_refs=normalize_entity_refs(data.get("entity_refs")),
embedding=embedding,
metadata=data.get("metadata", {}),
)

View file

@ -7,9 +7,11 @@ from .metrics import (
get_otel_meter,
get_otel_metrics,
get_otel_metrics_status,
register_otel_metric_attribute_provider,
reset_otel_metrics,
set_otel_metrics,
shutdown_otel_metrics,
unregister_otel_metric_attribute_provider,
)
from .tracing import (
HeadroomTracer,
@ -29,6 +31,7 @@ __all__ = [
"get_otel_meter",
"get_otel_metrics",
"get_otel_metrics_status",
"register_otel_metric_attribute_provider",
"HeadroomTracer",
"LangfuseTracingConfig",
"configure_langfuse_tracing",
@ -40,4 +43,5 @@ __all__ = [
"set_headroom_tracer",
"shutdown_headroom_tracing",
"shutdown_otel_metrics",
"unregister_otel_metric_attribute_provider",
]

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Literal
@ -26,6 +27,67 @@ _global_metrics: HeadroomOtelMetrics | None = None
_owned_meter_provider: Any | None = None
_owned_metrics_config: OTelMetricsConfig | None = None
MetricAttributeProvider = Callable[[], Mapping[str, Any]]
_metric_attribute_providers: list[MetricAttributeProvider] = []
_metric_attribute_providers_lock = Lock()
_MAX_DYNAMIC_ATTRIBUTES = 16
_MAX_DYNAMIC_ATTRIBUTE_LENGTH = 256
def register_otel_metric_attribute_provider(
provider: MetricAttributeProvider,
) -> MetricAttributeProvider:
"""Add request-scoped attributes to every Headroom OTEL datapoint.
Extensions use this narrow seam for dimensions such as tenant, team, or
user identity without coupling the OSS metrics layer to an auth package.
Providers run in the request context and must return content-free scalar
labels. A failing provider is ignored so observability cannot break traffic.
"""
with _metric_attribute_providers_lock:
if provider not in _metric_attribute_providers:
_metric_attribute_providers.append(provider)
return provider
def unregister_otel_metric_attribute_provider(provider: MetricAttributeProvider) -> None:
"""Remove a previously registered request-attribute provider."""
with _metric_attribute_providers_lock:
try:
_metric_attribute_providers.remove(provider)
except ValueError:
pass
def _dynamic_metric_attributes() -> dict[str, Any]:
with _metric_attribute_providers_lock:
providers = tuple(_metric_attribute_providers)
resolved: dict[str, Any] = {}
for provider in providers:
try:
attributes = provider()
except Exception:
logger.debug("OTEL metric attribute provider failed", exc_info=True)
continue
if not isinstance(attributes, Mapping):
continue
for raw_key, raw_value in attributes.items():
if len(resolved) >= _MAX_DYNAMIC_ATTRIBUTES:
return resolved
key = str(raw_key).strip()
if not key or raw_value is None or raw_value == "":
continue
if not isinstance(raw_value, (str, bool, int, float)):
continue
value = raw_value
if isinstance(value, str):
value = value[:_MAX_DYNAMIC_ATTRIBUTE_LENGTH]
resolved[key[:_MAX_DYNAMIC_ATTRIBUTE_LENGTH]] = value
return resolved
def _headroom_version() -> str:
return get_version()
@ -163,6 +225,21 @@ class HeadroomOtelMetrics:
description="Output tokens returned by upstream providers.",
unit="1",
)
self._proxy_attempted_input_tokens = self._meter.create_counter(
"headroom.proxy.tokens.attempted_input",
description=("Input tokens Headroom attempted to optimize before compression."),
unit="1",
)
self._proxy_output_saved_tokens = self._meter.create_counter(
"headroom.proxy.tokens.output_saved",
description="Estimated output tokens avoided by Headroom optimization.",
unit="1",
)
self._proxy_savings_usd = self._meter.create_counter(
"headroom.proxy.savings.usd",
description=("Estimated savings in USD by distinct Headroom or provider-cache layer."),
unit="USD",
)
self._proxy_saved_tokens = self._meter.create_counter(
"headroom.proxy.tokens.saved",
description=(
@ -265,6 +342,21 @@ class HeadroomOtelMetrics:
description="Waste tokens detected in compressed inputs.",
unit="1",
)
self._savings_attribution_events = self._meter.create_counter(
"headroom.savings.attribution.events",
description="Per-request savings attribution events.",
unit="1",
)
self._savings_attributed_tokens = self._meter.create_counter(
"headroom.savings.attributed.tokens",
description="Tokens attributed to a named savings source.",
unit="1",
)
self._savings_attributed_usd = self._meter.create_up_down_counter(
"headroom.savings.attributed.usd",
description="Attributed cost delta; negative values represent added cost.",
unit="USD",
)
# Backing values updated by record_subscription_window()
self._sub_5h_util_val: float = 0.0
@ -337,7 +429,9 @@ class HeadroomOtelMetrics:
@staticmethod
def _attrs(**attrs: Any) -> dict[str, Any]:
filtered: dict[str, Any] = {}
# Dynamic request dimensions are deliberately lower precedence than
# canonical instrument dimensions (provider/model/source/etc.).
filtered = _dynamic_metric_attributes()
for key, value in attrs.items():
if value is None or value == "":
continue
@ -362,8 +456,21 @@ class HeadroomOtelMetrics:
cache_write_5m_tokens: int = 0,
cache_write_1h_tokens: int = 0,
uncached_input_tokens: int = 0,
attempted_input_tokens: int = 0,
output_tokens_saved: int = 0,
savings_usd: Mapping[str, float] | None = None,
project: str | None = None,
client: str | None = None,
) -> None:
attrs = self._attrs(provider=provider, model=model, cached=cached)
attrs = self._attrs(
provider=provider,
model=model,
cached=cached,
**{
"headroom.project": project,
"headroom.client": client,
},
)
self._proxy_requests.add(1, attrs)
if cached:
@ -371,6 +478,17 @@ class HeadroomOtelMetrics:
self._proxy_input_tokens.add(max(input_tokens, 0), attrs)
self._proxy_output_tokens.add(max(output_tokens, 0), attrs)
if attempted_input_tokens > 0:
self._proxy_attempted_input_tokens.add(attempted_input_tokens, attrs)
if output_tokens_saved > 0:
self._proxy_output_saved_tokens.add(output_tokens_saved, attrs)
for source, value in (savings_usd or {}).items():
amount = max(float(value or 0.0), 0.0)
if amount:
self._proxy_savings_usd.add(
amount,
{**attrs, "source": str(source)[:64], "estimated": True},
)
compression_saved = max(tokens_saved, 0)
tool_schema_saved = max(tool_search_saved, 0)
self._proxy_saved_tokens.add(compression_saved + tool_schema_saved, attrs)
@ -402,6 +520,21 @@ class HeadroomOtelMetrics:
def record_proxy_failed(self, *, provider: str | None = None, model: str | None = None) -> None:
self._proxy_failed_requests.add(1, self._attrs(provider=provider, model=model))
def record_savings_attribution(self, items: list[dict[str, Any]]) -> None:
for item in items:
attrs = self._attrs(
source=str(item.get("source") or "other")[:64],
realized=bool(item.get("realized", True)),
estimated=bool(item.get("estimated", False)),
)
self._savings_attribution_events.add(1, attrs)
saved = max(0, int(item.get("tokens", 0) or 0))
if saved:
self._savings_attributed_tokens.add(saved, attrs)
cost = float(item.get("usd", 0.0) or 0.0)
if cost:
self._savings_attributed_usd.add(cost, attrs)
def record_proxy_rate_limited(
self,
*,

View file

@ -15,6 +15,7 @@ import os
import re
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from headroom import paths as _paths
from headroom.pricing.litellm_pricing import resolve_litellm_model
@ -134,6 +135,16 @@ def _parse_kv(kv_str: str) -> dict[str, str]:
return result
def _decode_perf_savings(value: str) -> list[dict[str, object]]:
# Local import keeps the analyzer usable against old logs/install layouts.
try:
from headroom.proxy.savings_attribution import decode
return decode(value)
except Exception:
return []
@dataclass
class PerfRecord:
"""A single parsed PERF log entry."""
@ -146,6 +157,12 @@ class PerfRecord:
tokens_before: int = 0
tokens_after: int = 0
tokens_saved: int = 0
# Tokens the forwarded request GREW by (PERF ``tok_inflated``). Both
# endpoints are clamped — ``tok_saved`` at zero and ``tok_inflated`` at zero
# — so a turn that left the proxy bigger reports ``tok_saved=0`` and hides
# its growth in a field nothing downstream read. Carrying it here is what
# lets the report state net alongside gross instead of implying they agree.
tokens_inflated: int = 0
tool_saved: int = 0
cache_read: int = 0
cache_write: int = 0
@ -156,6 +173,12 @@ class PerfRecord:
tokens_out: int = 0
ttfb_ms: float = 0.0
stages: dict[str, float] = field(default_factory=dict)
savings_breakdown: list[dict[str, object]] = field(default_factory=list)
# True when the proxy answered from its own response cache and never
# contacted the upstream. Such a turn has all-zero token counters and no
# upstream stage timings, so without this flag it reads as a turn that
# did nothing (#3019). Absent from pre-#3019 logs, hence the default.
from_response_cache: bool = False
@dataclass
@ -202,6 +225,10 @@ class PerfReport:
transform_records: list[TransformRecord] = field(default_factory=list)
toin_records: list[ToinRecord] = field(default_factory=list)
log_files_read: int = 0
# Rotated files skipped unopened because they were last written before the
# requested window. Reported so coverage stays honest: `log_files_read` on
# its own would silently understate how much log exists on disk.
log_files_skipped: int = 0
total_lines_parsed: int = 0
# Window covered by the report. `requested_hours` is what the caller
# asked for; `oldest_kept_ts` / `newest_kept_ts` are the actual
@ -286,7 +313,31 @@ def parse_log_files(last_n_hours: float = 168.0) -> PerfReport:
report.newest_kept_ts = ts_str
# Collect log files: proxy.log, proxy.log.1, proxy.log.2, ...
log_files = sorted(log_dir.glob("proxy.log*"), key=lambda p: p.stat().st_mtime)
#
# A rotated file last written before the cutoff cannot contain a record
# inside the window, so skip it without opening it. Without this the cost
# of a windowed query is O(total log history) rather than O(window):
# `/stats` recomputes throughput over the last hour on a 10s cache TTL, so
# a dashboard polling it re-read and re-regexed every byte of every
# rotated log, forever, for an answer that lives in the tail of the newest
# file. Measured on a developer machine with six rotations (54 MB).
#
# mtime is the safe discriminator: the logs are append-only, so a file
# untouched since before the cutoff has no line written after it. Files
# are stat'd once and the value reused for the sort.
cutoff_epoch = cutoff.timestamp() if cutoff is not None else None
dated_files: list[tuple[float, Path]] = []
for path in log_dir.glob("proxy.log*"):
try:
mtime = path.stat().st_mtime
except OSError:
# Rotated away between glob and stat — nothing to read.
continue
if cutoff_epoch is not None and mtime < cutoff_epoch:
report.log_files_skipped += 1
continue
dated_files.append((mtime, path))
log_files = [path for _, path in sorted(dated_files, key=lambda pair: pair[0])]
for log_file in log_files:
report.log_files_read += 1
@ -352,7 +403,9 @@ def parse_log_files(last_n_hours: float = 168.0) -> PerfReport:
tokens_before=int(kv.get("tok_before", 0)),
tokens_after=int(kv.get("tok_after", 0)),
tokens_saved=int(kv.get("tok_saved", 0)),
tokens_inflated=int(kv.get("tok_inflated", 0)),
tool_saved=int(kv.get("tool_saved", 0)),
savings_breakdown=_decode_perf_savings(kv.get("savings", "none")),
cache_read=int(kv.get("cache_read", 0)),
cache_write=int(kv.get("cache_write", 0)),
cache_hit_pct=int(kv.get("cache_hit_pct", 0)),
@ -361,6 +414,7 @@ def parse_log_files(last_n_hours: float = 168.0) -> PerfReport:
total_ms=float(kv.get("total_ms", 0)),
tokens_out=int(kv.get("tok_out", 0)),
ttfb_ms=float(kv.get("ttfb_ms", 0)),
from_response_cache=kv.get("cached", "0") == "1",
stages=stages_by_rid.get(m.group("rid"), {}),
)
)
@ -499,6 +553,19 @@ def format_report(report: PerfReport) -> str:
# include tool bytes), so it used to render as a rival "Tool saved" line — which
# read as a side metric and hid the win on tool-heavy turns where tok_saved=0.
lines.append(f"Tokens saved: {total_headline_saved:,} ({headline_pct:.1f}% reduction)")
# Gross vs net. ``tok_saved`` is clamped at zero per request, so turns
# where Headroom made the body BIGGER (CCR proactive expansion, memory
# injection) contribute nothing negative to the headline — their growth
# lands in ``tok_inflated`` instead, which nothing here used to read.
# Printing "321,239,562 -> 313,274,727" directly above "8,455,763 saved"
# implies the two reconcile; they differ by exactly the inflation. Show
# it whenever it is non-zero so the arithmetic closes on the page.
total_inflated = sum(r.tokens_inflated for r in records)
if total_inflated > 0:
lines.append(
f" · inflated {total_inflated:,} "
f"(net message reduction {total_before - total_after:,})"
)
if total_tool_saved > 0:
lines.append(f" · messages {max(0, total_saved):,}")
lines.append(f" · tool schemas {total_tool_saved:,}")
@ -512,19 +579,37 @@ def format_report(report: PerfReport) -> str:
lines.append("Per-Model Breakdown")
lines.append("-" * 40)
for model, model_recs in sorted(by_model.items()):
# Same all-layers construction as the headline above. This loop used to
# sum ``tokens_saved`` alone, so every row reported message compression
# only while the headline it sat under counted deferral too. The rows
# then failed to add up to the total printed inches above them — in the
# report that prompted this, four rows summing to 36,071 under a
# headline of 625,277, because 589,206 tokens of tool-schema deferral
# had no row to land in. A tool-heavy model read "0 tokens saved".
m_saved = sum(r.tokens_saved for r in model_recs)
m_tool_saved = sum(r.tool_saved for r in model_recs)
m_headline_saved = m_saved + m_tool_saved
m_before = sum(r.tokens_before for r in model_recs)
m_pct = (m_saved / m_before * 100) if m_before > 0 else 0
m_headline_before = m_before + m_tool_saved
m_pct = (m_headline_saved / m_headline_before * 100) if m_headline_before > 0 else 0
list_price = _get_list_price(model)
price_str = f"${list_price:.2f}/MTok" if list_price else "unknown"
est_str = (
f" ~${m_saved * list_price / 1_000_000:.2f} at list price" if list_price else ""
f" ~${m_headline_saved * list_price / 1_000_000:.2f} at list price"
if list_price
else ""
)
lines.append(
f" {model}: {len(model_recs)} reqs, "
f"{m_saved:,} tokens saved ({m_pct:.0f}%), "
f"{m_headline_saved:,} tokens saved ({m_pct:.0f}%), "
f"list price {price_str}{est_str}"
)
# Only split the row when there is a split to show; a compression-only
# model keeps the single-line shape it has always had.
if m_tool_saved > 0:
lines.append(
f" · messages {max(0, m_saved):,} · tool schemas {m_tool_saved:,}"
)
lines.append(" * Actual bill savings depend on provider caching behavior")
lines.append("")
@ -651,6 +736,31 @@ def format_report(report: PerfReport) -> str:
lines.append(
f" {name}: {avg_pct:.1f}% avg reduction, {len(recs)} uses, {total_s:,} saved"
)
# This table is built ONLY from "Transform NAME: B -> A tokens (saved N)"
# lines, which just one engine emits (transforms/pipeline.py). The
# OpenAI-Responses engine (transforms/compression_units.py +
# compression_batches.py) applies the same strategies and contains no
# logging calls at all, so none of its work appears above. On real
# traffic that hid ~7M of ~8.5M message-token savings — the table read
# "content_router: 189,783 saved" against a PERF total 44x larger, which
# invites exactly the wrong conclusion about which compressors work.
#
# State the divergence, NOT a coverage ratio. The two totals are
# different populations and neither strictly contains the other: the
# Transform lines carry no request_id, fire once per pipeline STAGE (so
# several can describe one request), and are emitted before the forwarder
# decides anything — a mutation later discarded by the signed-thinking
# byte-lock still logs its "saved" here while the request's PERF line
# correctly reports 0. So "table covers X of Y" would be a false subset
# claim in both directions; report the two sums and let the reader judge.
table_total = sum(r.tokens_saved for r in report.transform_records)
perf_total = sum(r.tokens_saved for r in report.perf_records)
if table_total != perf_total:
lines.append(
f" ! stage-level total {table_total:,} != PERF message total {perf_total:,} "
"— this table sees only engines that emit a Transform line, counts "
"per stage, and does not check whether the mutation shipped"
)
lines.append("")
# Router routing breakdown
@ -670,11 +780,24 @@ def format_report(report: PerfReport) -> str:
f" Excluded: {total_excluded} ({total_excluded / total_all * 100:.0f}%) — Read/Glob outputs"
)
lines.append(
f" Skipped: {total_skipped} ({total_skipped / total_all * 100:.0f}%) — <50 words"
f" Skipped: {total_skipped} ({total_skipped / total_all * 100:.0f}%) — below size floor"
)
lines.append(
f" Unchanged: {total_unchanged} ({total_unchanged / total_all * 100:.0f}%) — ratio too high"
)
# These four buckets are NOT the router's full outcome space — the
# `[router] route_counts=` line carries 17 keys, and the ones omitted
# here (cache_hit, system_msg, error_protected, already_compressed,
# …) are individually larger than "Excluded". Percentages taken over
# this subset therefore overstate every share: on real traffic the
# "skipped" bucket read 77% here against 49.5% of actual terminal
# fates, which reads as a mis-set threshold rather than a narrow
# denominator. Say what the denominator is instead of implying it is
# everything.
lines.append(
f" (shares are of these 4 buckets only, n={total_all}; "
"see `[router] route_counts=` for the full outcome space)"
)
if total_excluded > total_compressed * 3:
lines.append(" ! Excluded tools dominate — consider compressing stale Read outputs")
lines.append("")
@ -752,6 +875,11 @@ PERF_RECORD_FIELDS = [
"tokens_out",
"ttfb_ms",
"stages",
"savings_breakdown",
# Appended last so every existing CSV column keeps its position; a reader
# that indexes by name is unaffected either way.
"from_response_cache",
"tokens_inflated",
]
@ -1001,7 +1129,9 @@ def build_perf_summary(report: PerfReport) -> dict:
for model, recs in sorted(by_model_groups.items()):
m_before = sum(r.tokens_before for r in recs)
m_after = sum(r.tokens_after for r in recs)
m_saved = sum(r.tokens_saved for r in recs)
m_message_saved = sum(r.tokens_saved for r in recs)
m_tool_saved = sum(r.tool_saved for r in recs)
m_saved = m_message_saved + m_tool_saved
by_model.append(
{
"model": model,
@ -1009,7 +1139,9 @@ def build_perf_summary(report: PerfReport) -> dict:
"tokens_before": m_before,
"tokens_after": m_after,
"tokens_saved": m_saved,
"savings_pct": _pct(m_saved, m_before),
"message_tokens_saved": m_message_saved,
"tool_tokens_saved": m_tool_saved,
"savings_pct": _pct(m_saved, m_before + m_tool_saved),
"list_price_per_mtok": _get_list_price(model),
}
)
@ -1033,6 +1165,37 @@ def build_perf_summary(report: PerfReport) -> dict:
}
)
by_source_groups: dict[tuple[str, bool], dict[str, int | float | str | bool]] = {}
for record in records:
for item in record.savings_breakdown:
source = str(item.get("source") or "other")
realized = bool(item.get("realized", True))
key = (source, realized)
row = by_source_groups.setdefault(
key,
{
"source": source,
"realized": realized,
"events": 0,
"tokens": 0,
"usd": 0.0,
},
)
row["events"] = int(row["events"]) + 1
raw_tokens = item.get("tokens", 0)
raw_usd = item.get("usd", 0.0)
tokens = int(raw_tokens) if isinstance(raw_tokens, (str, int, float)) else 0
usd = float(raw_usd) if isinstance(raw_usd, (str, int, float)) else 0.0
row["tokens"] = int(row["tokens"]) + max(0, tokens)
row["usd"] = round(
float(row["usd"]) + usd,
12,
)
by_source = sorted(
by_source_groups.values(),
key=lambda row: (-int(row["tokens"]), str(row["source"])),
)
return {
"window_hours": report.requested_hours,
"actual_window": {
@ -1056,6 +1219,7 @@ def build_perf_summary(report: PerfReport) -> dict:
"cache_hit_pct": cache_hit_pct,
"by_model": by_model,
"by_transform": by_transform,
"by_source": by_source,
"overhead": build_overhead_summary(report),
"throughput": calculate_throughput(report),
"log_files_read": report.log_files_read,

View file

@ -282,6 +282,47 @@ def estimate_cost(
return input_cost + output_cost
def estimate_cost_from_tokens(
model: str,
input_tokens: int = 0,
output_tokens: int = 0,
cached_tokens: int = 0,
) -> float | None:
"""Cost for one request from token counts, using LiteLLM's own cost model.
Prefer this over :func:`estimate_cost` whenever a request may carry cached
tokens or exceed a model's long-context threshold. Flat per-1M rates cannot
express either: cache reads bill at their own rate, and on Anthropic's
Sonnet 4 / 4.5 family a prompt over 200K re-prices the *whole* request --
input, output and cache alike. ``litellm.cost_per_token`` applies both.
``input_tokens`` is the TOTAL prompt, ``cached_tokens`` included. LiteLLM
subtracts the cached portion itself and tests the long-context threshold
against the total, so passing a cache-exclusive count would both
double-discount the cached tokens and understate the threshold.
Returns ``None`` when LiteLLM is unavailable (the dependency is gated
``python_version < '3.14'``) or doesn't know the model -- the caller's cue
to fall back to its own table.
"""
if not LITELLM_AVAILABLE:
return None
candidate = next((c for c in pricing_lookup_candidates(model) if c in litellm.model_cost), None)
if candidate is None:
return None
try:
prompt_cost, completion_cost = litellm.cost_per_token(
model=candidate,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
cache_read_input_tokens=cached_tokens,
)
except Exception as exc: # pragma: no cover - depends on litellm internals
logger.debug("litellm.cost_per_token failed for %s: %s", candidate, exc)
return None
return float(prompt_cost) + float(completion_cost)
def list_available_models() -> list[str]:
"""List all models with pricing info in LiteLLM's database.

View file

@ -24,6 +24,7 @@ import warnings
from typing import Any, cast
from headroom import paths as _paths
from headroom.pricing.litellm_pricing import estimate_cost_from_tokens
from headroom.tokenizers.base import (
TokenCountCache,
coerce_countable_text,
@ -67,6 +68,21 @@ def sanitize_anthropic_model_id(model: str) -> str:
return _DANGLING_ANSI_STYLE_SUFFIX_RE.sub("", cleaned)
# `[1m]` is not only an ANSI artifact: Claude Code appends it to a model id to
# request the 1M context tier, and only then sends the `context-1m` beta header
# (#1158). Upstream rejects the suffix, so `sanitize_anthropic_model_id()` must
# keep stripping it before forwarding (#2027) — but the tier it encodes has to
# be read off the id *before* that happens, or a 1M request gets budgeted as if
# it were the base model's window.
_CONTEXT_1M_SUFFIX_RE = re.compile(r"(?:\[1m\])+$")
CONTEXT_1M_TOKENS = 1_000_000
def has_context_1m_suffix(model: str) -> bool:
"""Return True if ``model`` carries Claude Code's ``[1m]`` 1M-tier marker."""
return bool(_CONTEXT_1M_SUFFIX_RE.search(_ANSI_ESCAPE_RE.sub("", str(model)).strip()))
def sanitize_anthropic_model_metadata(value: Any) -> Any:
"""Strip model-id styling artifacts from Anthropic model metadata payloads."""
if isinstance(value, list):
@ -154,6 +170,40 @@ ANTHROPIC_PRICING: dict[str, dict[str, float]] = {
"claude-3-haiku-20240307": {"input": 0.25, "output": 1.25, "cached_input": 0.03},
}
# Anthropic's long-context premium. On models that reach 1M over a 200K base,
# a prompt above 200K re-prices the *entire* request -- input, output and cache
# alike -- rather than only the tokens past the threshold. Multipliers are
# derived from LiteLLM's `*_above_200k_tokens` fields ($3->$6 in, $15->$22.50
# out, $0.30->$0.60 cache read).
#
# Only the Sonnet 4 / 4.5 family is tiered: Opus, and Sonnet 4.6 onward, are
# flat-rated across their whole window. This is the same population that needs
# the `[1m]` suffix to reach 1M at all, so a session that fills the window this
# unlocks is billed at these rates.
_LONG_CONTEXT_THRESHOLD = 200_000
_LONG_CONTEXT_PREMIUM: dict[str, float] = {"input": 2.0, "output": 1.5, "cached_input": 2.0}
_LONG_CONTEXT_TIERED_MODELS = (
"claude-sonnet-4-5",
"claude-sonnet-4-20250514",
"claude-4-sonnet-20250514",
)
def _apply_long_context_premium(
model: str, pricing: dict[str, float], input_tokens: int
) -> dict[str, float]:
"""Return ``pricing`` scaled by the long-context premium where it applies.
Used only on the manual fallback path; the LiteLLM path already applies the
published above-threshold rates itself.
"""
if input_tokens <= _LONG_CONTEXT_THRESHOLD:
return pricing
if not any(model.startswith(tiered) for tiered in _LONG_CONTEXT_TIERED_MODELS):
return pricing
return {key: rate * _LONG_CONTEXT_PREMIUM.get(key, 1.0) for key, rate in pricing.items()}
# Default limits for pattern-based inference
# Used when a model isn't in the explicit list but matches a known pattern
_PATTERN_DEFAULTS = {
@ -226,6 +276,11 @@ def _load_custom_model_config() -> dict[str, Any]:
# Try to parse as JSON string
loaded = json.loads(env_config)
if not isinstance(loaded, dict):
raise ValueError(
f"HEADROOM_MODEL_LIMITS must be a JSON object, got {type(loaded).__name__}"
)
# Check for anthropic-specific config, fall back to root level
anthropic_config = loaded.get("anthropic", loaded)
if "context_limits" in anthropic_config:
@ -234,7 +289,10 @@ def _load_custom_model_config() -> dict[str, Any]:
config["pricing"].update(anthropic_config["pricing"])
logger.debug(f"Loaded custom model config from HEADROOM_MODEL_LIMITS: {loaded}")
except (json.JSONDecodeError, OSError) as e:
except (ValueError, OSError) as e:
# ValueError covers json.JSONDecodeError (a subclass) and the
# non-object guard above, so a malformed value warns and falls back
# to defaults instead of crashing provider init.
logger.warning(f"Failed to load HEADROOM_MODEL_LIMITS: {e}")
# Check config file. Prefer the canonical config-dir location, then fall
@ -249,6 +307,9 @@ def _load_custom_model_config() -> dict[str, Any]:
with open(config_file, encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError(f"{config_file} must contain a JSON object")
# Only load anthropic-specific config
anthropic_config = loaded.get("anthropic", loaded)
if "context_limits" in anthropic_config:
@ -262,7 +323,7 @@ def _load_custom_model_config() -> dict[str, Any]:
config["pricing"][model] = pricing
logger.debug(f"Loaded custom model config from {config_file}")
except (json.JSONDecodeError, OSError) as e:
except (ValueError, OSError) as e:
logger.warning(f"Failed to load {config_file}: {e}")
return config
@ -605,8 +666,16 @@ class AnthropicProvider(Provider):
6. Pattern-based inference (opus/sonnet/haiku)
7. Default fallback (200K for any Claude model)
A ``[1m]`` suffix raises the result to at least 1M: the caller asked for
the 1M tier and Claude Code sent the `context-1m` beta header, so the
real upstream window is 1M even when the base model's default is 200K.
Never raises an exception - uses sensible defaults for unknown models.
"""
if has_context_1m_suffix(model):
# Recursion terminates: the sanitized id has no `[1m]` left.
base = self.get_context_limit(sanitize_anthropic_model_id(model))
return max(base, CONTEXT_1M_TOKENS)
model = sanitize_anthropic_model_id(model)
# Check explicit and loaded limits
if model in self._context_limits:
@ -685,58 +754,38 @@ class AnthropicProvider(Provider):
"""Estimate cost for a request.
Tries LiteLLM first for up-to-date pricing, falls back to manual pricing.
Both paths apply Anthropic's long-context premium: on the Sonnet 4 / 4.5
family a prompt over 200K re-prices the whole request (see
``_LONG_CONTEXT_PREMIUM``).
"""
model = sanitize_anthropic_model_id(model)
# Try LiteLLM first for cost estimation
litellm, litellm_get_model_info = _get_litellm_clients()
if litellm is not None:
try:
cost = litellm.completion_cost(
model=model,
prompt="",
completion="",
prompt_tokens=input_tokens - cached_tokens,
completion_tokens=output_tokens,
)
# Add cached token cost if applicable
if cached_tokens > 0:
try:
# Get cached input pricing from LiteLLM model info
info = (
litellm_get_model_info(model)
if litellm_get_model_info is not None
else None
)
if info and "input_cost_per_token" in info:
# LiteLLM typically applies 90% discount for cached tokens
cached_cost = cached_tokens * info["input_cost_per_token"] * 0.1
cost += cached_cost
except Exception:
# Fall back to manual cached pricing
pricing = self._get_pricing(model)
if pricing:
cached_cost = (cached_tokens / 1_000_000) * pricing.get(
"cached_input", pricing["input"]
)
cost += cached_cost
return cost # type: ignore[no-any-return]
except Exception as e:
logger.debug(f"LiteLLM cost estimation failed for {model}: {e}")
# LiteLLM knows per-model cache and long-context rates, so let it price
# the whole request rather than rebuilding the rate card here.
cost = estimate_cost_from_tokens(
model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
)
if cost is not None:
return cost
# Fall back to manual pricing
pricing = self._get_pricing(model)
if not pricing:
return None
rates = _apply_long_context_premium(model, pricing, input_tokens)
# Calculate cost
non_cached_input = input_tokens - cached_tokens
cost = (
(non_cached_input / 1_000_000) * pricing["input"]
+ (cached_tokens / 1_000_000) * pricing.get("cached_input", pricing["input"])
+ (output_tokens / 1_000_000) * pricing["output"]
(non_cached_input / 1_000_000) * rates["input"]
+ (cached_tokens / 1_000_000) * rates.get("cached_input", rates["input"])
+ (output_tokens / 1_000_000) * rates["output"]
)
return cost # type: ignore[no-any-return]
return cost
def _get_pricing(self, model: str) -> dict[str, float] | None:
"""Get pricing for a model with fallback logic."""

View file

@ -1,6 +1,7 @@
"""Claude-specific provider helpers."""
from .runtime import (
CLAUDE_AUTH_KEYS,
DEFAULT_API_URL,
REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_GATED_MIN_VERSION,
@ -8,6 +9,8 @@ from .runtime import (
REMOTE_CONTROL_SIBLING_GATE_NOTE,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
claude_auth_conflict_message,
claude_auth_conflict_sources,
detect_claude_code_version,
is_custom_anthropic_base_url,
parse_claude_code_version,
@ -25,6 +28,7 @@ from .vscode import (
)
__all__ = [
"CLAUDE_AUTH_KEYS",
"claude_user_settings_path",
"configure_vscode_claude_settings",
"remove_vscode_claude_settings",
@ -36,6 +40,8 @@ __all__ = [
"REMOTE_CONTROL_SIBLING_GATE_NOTE",
"TOOL_SEARCH_DEFAULT",
"TOOL_SEARCH_ENV",
"claude_auth_conflict_message",
"claude_auth_conflict_sources",
"detect_claude_code_version",
"is_custom_anthropic_base_url",
"parse_claude_code_version",

View file

@ -18,6 +18,7 @@ TOOL_SEARCH_DEFAULT = "true"
TOOL_SEARCH_FOUNDRY_DEFAULT = "false"
REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL"
REMOTE_CONTROL_FEATURE = "Remote Control"
CLAUDE_AUTH_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
# GH #1779: Claude Code v2.1.196 added a client-side eligibility check that
# DISABLES first-party Remote Control (`/remote-control` / `/rc`, which mirrors a
@ -186,6 +187,46 @@ def remote_control_applies_to_auth(environ: Mapping[str, object]) -> bool:
)
def claude_auth_conflict_sources(
*layers: tuple[str, Mapping[str, object]],
) -> dict[str, str] | None:
"""Return source labels when both mutually exclusive Claude auth keys are effective.
Layers are ordered from lowest to highest precedence. Empty values clear an
inherited value, matching environment overlay semantics. Credential values
are deliberately never returned so callers cannot leak them in diagnostics.
"""
effective: dict[str, str] = {}
sources: dict[str, str] = {}
for source, values in layers:
for key in CLAUDE_AUTH_KEYS:
if key not in values:
continue
value = str(values.get(key) or "").strip()
if value:
effective[key] = value
sources[key] = source
else:
effective.pop(key, None)
sources.pop(key, None)
if all(key in effective for key in CLAUDE_AUTH_KEYS):
return {key: sources[key] for key in CLAUDE_AUTH_KEYS}
return None
def claude_auth_conflict_message(sources: Mapping[str, str]) -> str:
"""Format a value-free remediation for contradictory Claude credentials."""
api_source = sources.get("ANTHROPIC_API_KEY", "effective configuration")
token_source = sources.get("ANTHROPIC_AUTH_TOKEN", "effective configuration")
return (
"Claude Code has both ANTHROPIC_API_KEY "
f"({api_source}) and ANTHROPIC_AUTH_TOKEN ({token_source}) set. "
"Claude rejects this ambiguous auth state before Headroom can proxy a request. "
"Keep ANTHROPIC_API_KEY for API-key billing, or keep ANTHROPIC_AUTH_TOKEN "
"for token/gateway auth; remove the other key from the named source and retry."
)
def parse_claude_code_version(text: str | None) -> tuple[int, int, int] | None:
"""Parse a ``MAJOR.MINOR.PATCH`` version out of ``claude --version`` output.

View file

@ -89,7 +89,11 @@ def configure_vscode_claude_settings(path: Path, proxy_url: str) -> str:
payload = _read_settings(path)
env = _env_map(payload, path)
state_path = _state_path(path)
managed = {_BASE_URL_KEY: proxy_url, _TOOL_SEARCH_KEY: "true"}
# Claude Code's VS Code webview cannot render the server_tool_use /
# tool_search_tool_result blocks emitted by deferred tool search (#2028).
# Keep it disabled for this surface; the standalone CLI retains its own
# configurable/default-on policy.
managed = {_BASE_URL_KEY: proxy_url, _TOOL_SEARCH_KEY: "false"}
if state_path.exists():
state = _read_object(state_path, label="Headroom state")

View file

@ -128,9 +128,13 @@ async def handle_codex_live_websocket(
)
forwarded_headers = await apply_copilot_api_auth(forwarded_headers, url=upstream_url)
config = getattr(proxy, "config", None)
# `openai_base_url` comes from the resolved provider target, not from a
# request header, so there is no per-request override to gate on here.
forwarded_headers = merge_extra_headers(
forwarded_headers,
getattr(config, "openai_extra_headers", None),
upstream_url=None,
config=config,
)
if not any(key.lower() == "authorization" for key in forwarded_headers):
if os.environ.get("OPENAI_API_KEY", "").strip():

View file

@ -21,6 +21,7 @@ import warnings
from datetime import date
from typing import Any
from headroom.pricing.litellm_pricing import estimate_cost_from_tokens
from headroom.tokenizers import EstimatingTokenCounter
from .base import Provider, TokenCounter
@ -326,18 +327,13 @@ class CohereProvider(Provider):
# Try LiteLLM first
if LITELLM_AVAILABLE:
for model_variant in [f"cohere/{model}", model]:
try:
cost = litellm.completion_cost(
model=model_variant,
prompt="",
completion="",
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
if cost is not None:
return float(cost)
except Exception:
pass
cost = estimate_cost_from_tokens(
model_variant,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
if cost is not None:
return float(cost)
# Fallback to built-in pricing
model_lower = model.lower()

View file

@ -17,6 +17,16 @@ from headroom.proxy.project_context import with_project_prefix
_MARKER_START = "// --- Headroom Copilot proxy ---"
_MARKER_END = "// --- end Headroom Copilot proxy ---"
_PROXY_KEY = "github.copilot.advanced.debug.overrideProxyUrl"
_CAPI_KEY = "github.copilot.advanced.debug.overrideCapiUrl"
# Written by Headroom until #3076: it no longer exists. The modern Copilot Chat
# extension — the only one left after `GitHub.copilot` was deprecated in early
# 2026 — defines no `authType` setting in either its own configuration
# (`advanced.authPermissions`, `advanced.authProvider`,
# `advanced.debug.overrideCapiUrl`, `advanced.debug.overrideProxyUrl`,
# `advanced.debug.use*Fetcher`) or in the completions code merged into it. Still
# recognised below so a stale hand-written copy is detected, but never emitted:
# VS Code flags unknown keys, and shipping one that does nothing invited the
# conclusion that the override mechanism had stopped working.
_AUTH_KEY = "github.copilot.advanced.debug.overrideAuthType"
@ -118,7 +128,7 @@ def _managed_block(proxy_url: str, *, owns_preceding_comma: bool, line_sep: str)
return (
f"\t{marker}{line_sep}"
f"\t{json.dumps(_PROXY_KEY)}: {json.dumps(proxy_url)},{line_sep}"
f'\t{json.dumps(_AUTH_KEY)}: "token"{line_sep}'
f"\t{json.dumps(_CAPI_KEY)}: {json.dumps(proxy_url)}{line_sep}"
f"\t{_MARKER_END}"
)
@ -161,7 +171,7 @@ def configure_vscode_proxy_settings(path: Path, proxy_url: str) -> str:
if had_managed_block:
remove_vscode_proxy_settings(path)
raw = _read_settings(path)
elif _PROXY_KEY in raw or _AUTH_KEY in raw:
elif _PROXY_KEY in raw or _CAPI_KEY in raw or _AUTH_KEY in raw:
raise click.ClickException(
f"{path} already configures a Copilot endpoint override outside Headroom's "
"managed block; refusing to replace it. Remove it or use --no-configure."

View file

@ -26,6 +26,7 @@ from datetime import date
from typing import Any
from headroom.models.registry import ModelRegistry
from headroom.pricing.litellm_pricing import estimate_cost_from_tokens
from headroom.tokenizers import EstimatingTokenCounter
from .base import Provider, TokenCounter
@ -346,18 +347,13 @@ class GoogleProvider(Provider):
model_lower, # gemini-1.5-pro
]
for variant in model_variants:
try:
cost = litellm.completion_cost(
model=variant,
prompt="",
completion="",
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
if cost is not None:
return cost
except Exception:
continue
cost = estimate_cost_from_tokens(
variant,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
if cost is not None:
return cost
# Fallback to hardcoded pricing
input_price, output_price = None, None

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
from headroom.providers.grok.runtime import DEFAULT_API_URL
from headroom.proxy.project_context import with_project_prefix
@ -41,6 +42,8 @@ def render_setup_lines(port: int, project: str | None = None) -> list[str]:
" [model.grok-build]",
f' base_url = "{target.base_url}"',
"",
f" Proxy upstream (OpenAI-compatible): {DEFAULT_API_URL}",
"",
" Start Grok Build in this project directory:",
" grok",
"",

View file

@ -25,6 +25,7 @@ import logging
import os
from typing import Any
from headroom.pricing.litellm_pricing import estimate_cost_from_tokens
from headroom.tokenizers import EstimatingTokenCounter
from .base import Provider, TokenCounter
@ -240,19 +241,13 @@ class LiteLLMProvider(Provider):
Returns:
Estimated cost in USD, or None if pricing unknown.
"""
try:
# LiteLLM's cost calculation
cost = litellm.completion_cost(
model=model,
prompt="", # We're using token counts directly
completion="",
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
return cost
except Exception as e:
logger.debug(f"LiteLLM cost estimation failed for {model}: {e}")
return None
# LiteLLM's cost calculation, from token counts directly.
return estimate_cost_from_tokens(
model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
)
@classmethod
def list_supported_providers(cls) -> list[str]:

View file

@ -16,6 +16,7 @@ from functools import lru_cache
from typing import Any, cast
from headroom import paths as _paths
from headroom.pricing.litellm_pricing import estimate_cost_from_tokens
from headroom.tokenizers.base import coerce_countable_text, count_content_blocks
from .base import Provider, TokenCounter
@ -199,6 +200,11 @@ def _load_custom_model_config() -> dict[str, Any]:
# Try to parse as JSON string
loaded = json.loads(env_config)
if not isinstance(loaded, dict):
raise ValueError(
f"HEADROOM_MODEL_LIMITS must be a JSON object, got {type(loaded).__name__}"
)
openai_config = loaded.get("openai", loaded)
if "context_limits" in openai_config:
config["context_limits"].update(openai_config["context_limits"])
@ -208,7 +214,10 @@ def _load_custom_model_config() -> dict[str, Any]:
config["encodings"].update(openai_config["encodings"])
logger.debug("Loaded custom OpenAI model config from HEADROOM_MODEL_LIMITS")
except (json.JSONDecodeError, OSError) as e:
except (ValueError, OSError) as e:
# ValueError covers json.JSONDecodeError (a subclass) and the
# non-object guard above, so a malformed value warns and falls back
# to defaults instead of crashing provider init.
logger.warning(f"Failed to load HEADROOM_MODEL_LIMITS: {e}")
# Check config file. Prefer the canonical config-dir location, then fall
@ -223,6 +232,9 @@ def _load_custom_model_config() -> dict[str, Any]:
with open(config_file, encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError(f"{config_file} must contain a JSON object")
openai_config = loaded.get("openai", {})
if "context_limits" in openai_config:
for model, limit in openai_config["context_limits"].items():
@ -238,7 +250,7 @@ def _load_custom_model_config() -> dict[str, Any]:
config["encodings"][model] = encoding
logger.debug(f"Loaded custom OpenAI model config from {config_file}")
except (json.JSONDecodeError, OSError) as e:
except (ValueError, OSError) as e:
logger.warning(f"Failed to load {config_file}: {e}")
return config
@ -637,20 +649,16 @@ class OpenAIProvider(Provider):
Returns:
Estimated cost in USD, or None if pricing unknown.
"""
# Try LiteLLM first (most up-to-date pricing)
litellm = _get_litellm_module()
if litellm is not None:
try:
# LiteLLM uses per-token pricing, returns total cost
cost = litellm.completion_cost(
model=model,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
if cost is not None and cost > 0:
return float(cost)
except Exception:
pass # Fall through to manual pricing
# Try LiteLLM first (most up-to-date pricing, and it knows each model's
# real cached-input rate rather than the manual path's flat estimate)
cost = estimate_cost_from_tokens(
model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
)
if cost is not None and cost > 0:
return float(cost)
# Fall back to hardcoded pricing
return self._estimate_cost_manual(input_tokens, output_tokens, model, cached_tokens)

View file

@ -32,9 +32,19 @@ OPENAI_RESPONSES_ROOT_PATHS: tuple[str, ...] = (
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
# Copilot Chat derives this unprefixed path from overrideCapiUrl. Without an
# explicit root route it falls through to uncompressed generic passthrough.
"/responses",
)
OPENAI_RESPONSES_WEBSOCKET_PATHS: tuple[str, ...] = OPENAI_RESPONSES_ROOT_PATHS
# The Codex websocket relay speaks a different protocol; do not register the
# Copilot HTTP alias as a websocket route without separate wire validation.
OPENAI_RESPONSES_WEBSOCKET_PATHS: tuple[str, ...] = (
"/v1/responses",
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
)
OPENAI_RESPONSES_SUBPATH_ROUTES: tuple[OpenAIResponsesSubpathRoute, ...] = (
OpenAIResponsesSubpathRoute("/v1/responses/{sub_path:path}", ("GET", "POST", "DELETE")),

View file

@ -12487,6 +12487,7 @@ var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROJECT_HEADER = "x-headroom-project";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() {
@ -12635,7 +12636,7 @@ function requestUrl(input) {
}
return new URL(String(input));
}
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@ -12647,9 +12648,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers;
}
function withRoutedFetchInput(input, init, proxy) {
function withRoutedFetchInput(input, init, proxy, project) {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
@ -12657,7 +12661,7 @@ function withRoutedFetchInput(input, init, proxy) {
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath)
headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
};
if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit];
@ -12703,12 +12707,15 @@ function urlFromRequestOptions(options) {
return void 0;
}
}
function headersForNodeRequest(options, upstream, originalPath) {
function headersForNodeRequest(options, upstream, originalPath, project) {
const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host");
const result = {};
headers.forEach((value, key) => {
@ -12716,7 +12723,7 @@ function headersForNodeRequest(options, upstream, originalPath) {
});
return result;
}
function routedNodeOptions(parts, proxy) {
function routedNodeOptions(parts, proxy, project) {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0;
}
@ -12747,7 +12754,7 @@ function routedNodeOptions(parts, proxy) {
hostname: nextUrl.hostname,
port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
};
}
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@ -12758,7 +12765,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
}
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
@ -12794,6 +12801,7 @@ function installHeadroomTransport(options) {
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
@ -12801,6 +12809,7 @@ function installHeadroomTransport(options) {
const state = {
refs: 1,
proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
@ -12821,7 +12830,7 @@ function installHeadroomTransport(options) {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit);
};
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
@ -12871,9 +12880,11 @@ function resolveProxyUrl(options) {
var HeadroomPlugin = async (input, options = {}) => {
const pluginOptions = options;
const proxyUrl = resolveProxyUrl(pluginOptions);
const project = pluginOptions.project ?? input.project?.id ?? input.directory;
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
const uninstallTransport = installHeadroomTransport({
proxyUrl,
project,
debug: pluginOptions.debug
});
return {
@ -12894,7 +12905,7 @@ var HeadroomPlugin = async (input, options = {}) => {
"shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT = pluginOptions.project ?? input.project.id ?? input.directory;
output.env.HEADROOM_PROJECT = project;
if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend;
}

View file

@ -8,6 +8,7 @@ var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROJECT_HEADER = "x-headroom-project";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() {
@ -156,7 +157,7 @@ function requestUrl(input) {
}
return new URL(String(input));
}
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@ -168,9 +169,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers;
}
function withRoutedFetchInput(input, init, proxy) {
function withRoutedFetchInput(input, init, proxy, project) {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
@ -178,7 +182,7 @@ function withRoutedFetchInput(input, init, proxy) {
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath)
headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
};
if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit];
@ -224,12 +228,15 @@ function urlFromRequestOptions(options) {
return void 0;
}
}
function headersForNodeRequest(options, upstream, originalPath) {
function headersForNodeRequest(options, upstream, originalPath, project) {
const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host");
const result = {};
headers.forEach((value, key) => {
@ -237,7 +244,7 @@ function headersForNodeRequest(options, upstream, originalPath) {
});
return result;
}
function routedNodeOptions(parts, proxy) {
function routedNodeOptions(parts, proxy, project) {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0;
}
@ -268,7 +275,7 @@ function routedNodeOptions(parts, proxy) {
hostname: nextUrl.hostname,
port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
};
}
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@ -279,7 +286,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
}
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
@ -315,6 +322,7 @@ function installHeadroomTransport(options) {
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
@ -322,6 +330,7 @@ function installHeadroomTransport(options) {
const state = {
refs: 1,
proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
@ -342,7 +351,7 @@ function installHeadroomTransport(options) {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit);
};
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);

View file

@ -6,7 +6,7 @@ from __future__ import annotations
import logging
from typing import Any
from fastapi import FastAPI, Request, WebSocket
from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.responses import Response
from headroom.providers.cloudcode import normalize_cloudcode_passthrough_path
@ -67,6 +67,7 @@ from headroom.proxy.passthrough import (
custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry,
)
from headroom.proxy.request_scope import normalize_request_path
from headroom.proxy.upstream_guard import is_safe_upstream_url
logger = logging.getLogger("headroom.proxy.routes")
@ -266,6 +267,9 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
# OpenAI-compatible and generic passthrough routes.
custom_base = request.headers.get("x-headroom-base-url", "").strip()
if custom_base:
if not is_safe_upstream_url(custom_base):
logger.warning("rejecting unsafe x-headroom-base-url: %r", custom_base)
raise HTTPException(status_code=400, detail="Rejected unsafe upstream base URL")
return await proxy.handle_anthropic_messages(
request, upstream_base_url=custom_base.rstrip("/")
)
@ -506,6 +510,9 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
async def passthrough(request: Request, path: str):
custom_base = request.headers.get("x-headroom-base-url")
if custom_base:
if not is_safe_upstream_url(custom_base):
logger.warning("rejecting unsafe x-headroom-base-url: %r", custom_base)
raise HTTPException(status_code=400, detail="Rejected unsafe upstream base URL")
base_url = custom_base.rstrip("/")
endpoint_name, provider_name = _custom_base_passthrough_telemetry(
request.method,
@ -530,5 +537,7 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
return await proxy.handle_passthrough(
request,
_select_passthrough_base_url(proxy, dict(request.headers)),
# The path matters here: this is where unrouted paths land, and
# Copilot's inline completions are one of them (#3076).
_select_passthrough_base_url(proxy, dict(request.headers), request.url.path),
)

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