mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge remote-tracking branch 'upstream/main' into maintain/pr-2585
# Conflicts: # headroom/proxy/cost.py
This commit is contained in:
commit
cc46f172c9
297 changed files with 23530 additions and 2358 deletions
23
.cargo/audit.toml
Normal file
23
.cargo/audit.toml
Normal 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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
.env.example
15
.env.example
|
|
@ -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
|
||||
|
|
|
|||
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -40,6 +40,16 @@ Closes #
|
|||
- Observed result:
|
||||
- Not tested:
|
||||
|
||||
## Runtime Rollout Safety
|
||||
|
||||
- Rollout-managed feature(s):
|
||||
- Minimum rollout channel:
|
||||
- Stable/default behavior changed:
|
||||
- Kill switch / disable path:
|
||||
- Unsafe override required:
|
||||
- Qualification impact:
|
||||
- Rollback path:
|
||||
|
||||
## Review Readiness
|
||||
|
||||
- [ ] I have performed a self-review
|
||||
|
|
|
|||
32
.github/act/pr-governance-valid.json
vendored
32
.github/act/pr-governance-valid.json
vendored
|
|
@ -1,19 +1,13 @@
|
|||
{
|
||||
"action": "ready_for_review",
|
||||
"number": 42,
|
||||
"pull_request": {
|
||||
"number": 42,
|
||||
"draft": false,
|
||||
"title": "feat: add PR governance",
|
||||
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\nCloses #123\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n- Added a commit-msg hook that runs commitlint locally.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n- [x] Manual testing performed\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR with an incomplete template, then fixed the body.\n- Observed result: The governance check failed until the template and readiness boxes were complete.\n- Not tested: Repository-level automatic Copilot rulesets.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
|
||||
"user": {
|
||||
"login": "octocat"
|
||||
},
|
||||
"base": {
|
||||
"sha": "dff6a199"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "JerrettDavis/headroom"
|
||||
}
|
||||
}
|
||||
{
|
||||
"action": "ready_for_review",
|
||||
"number": 42,
|
||||
"pull_request": {
|
||||
"number": 42,
|
||||
"draft": false,
|
||||
"title": "feat: add PR governance",
|
||||
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR and ran governance.\n- Observed result: The check passed with complete facts.\n- Not tested: Repository settings.\n\n## Runtime Rollout Safety\n\n- Rollout-managed feature(s): None.\n- Minimum rollout channel: Stable.\n- Stable/default behavior changed: No.\n- Kill switch / disable path: Not applicable.\n- Unsafe override required: No.\n- Qualification impact: None.\n- Rollback path: Revert the workflow and script changes.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
|
||||
"user": {"login": "octocat"},
|
||||
"base": {"sha": "dff6a199"}
|
||||
},
|
||||
"repository": {"full_name": "JerrettDavis/headroom"}
|
||||
}
|
||||
|
|
|
|||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -5,14 +5,14 @@
|
|||
},
|
||||
"metadata": {
|
||||
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
||||
"version": "0.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"
|
||||
|
|
|
|||
BIN
.github/pr-images/issue-2552-windows-fallback-verification.png
vendored
Normal file
BIN
.github/pr-images/issue-2552-windows-fallback-verification.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
44
.github/workflows/docker.yml
vendored
44
.github/workflows/docker.yml
vendored
|
|
@ -204,10 +204,10 @@ jobs:
|
|||
- name: Upload digest marker
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Variant + arch in the artifact name so the manifest job can
|
||||
# download with `pattern: digests-<variant>-*` to gather all
|
||||
# arches for one variant. `root` substitutes the empty-string
|
||||
# variant since GHA artifact names can't end in a hyphen.
|
||||
# Variant + arch uniquely identify the marker. The manifest job
|
||||
# downloads both architecture artifacts by exact name; a glob such
|
||||
# as `digests-code-*` would also match code-nonroot/code-slim.
|
||||
# `root` substitutes the empty-string variant.
|
||||
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
38
.github/workflows/release-metadata-sync.yml
vendored
38
.github/workflows/release-metadata-sync.yml
vendored
|
|
@ -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}"
|
||||
|
|
|
|||
33
.github/workflows/release-please.yml
vendored
33
.github/workflows/release-please.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/rust.yml
vendored
8
.github/workflows/rust.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
29
.github/workflows/tools-hash-refresh.yml
vendored
Normal file
29
.github/workflows/tools-hash-refresh.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
".": "0.34.0"
|
||||
".": "0.36.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
229
CHANGELOG.md
229
CHANGELOG.md
|
|
@ -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-01–07) ([#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->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 <2.0.0,>=1.28.1 to >=1.28.1,<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 <<ccr:...>> markers inline when no retrieve-tool path exists ([#2512](https://github.com/headroomlabs-ai/headroom/issues/2512)) ([ce8ce83](https://github.com/headroomlabs-ai/headroom/commit/ce8ce8313f8cebf060392a62f9adaab18c0df386))
|
||||
* **ccr:** tolerate null/malformed OpenAI data in response handling ([#2467](https://github.com/headroomlabs-ai/headroom/issues/2467)) ([e583e08](https://github.com/headroomlabs-ai/headroom/commit/e583e082d8dee942229ac6211c742f9c9448a905))
|
||||
* **ci:** publish latest from the root Docker manifest ([#2252](https://github.com/headroomlabs-ai/headroom/issues/2252)) ([5568d73](https://github.com/headroomlabs-ai/headroom/commit/5568d738afb5e080d8df56e64500026996cbf025))
|
||||
* **claude:** stop forcing tool search on Foundry ([#2477](https://github.com/headroomlabs-ai/headroom/issues/2477)) ([7981396](https://github.com/headroomlabs-ai/headroom/commit/798139608c0fb5118eb3a7a183b8b2abe92341f1))
|
||||
* **cli/update:** let install ownership win over bare /.dockerenv so venv installs self-update ([#2830](https://github.com/headroomlabs-ai/headroom/issues/2830)) ([7092b53](https://github.com/headroomlabs-ai/headroom/commit/7092b53c466bf5dbda8a1cda88403d1a4b16deb1))
|
||||
* **codex:** route alpha search through the Codex backend ([#2538](https://github.com/headroomlabs-ai/headroom/issues/2538)) ([a540eb2](https://github.com/headroomlabs-ai/headroom/commit/a540eb2c61b1a47e5ab8b07ea4a80fee780b6514))
|
||||
* **content-router:** protect custom-tag blocks before mixed-content section split ([d7bc1e2](https://github.com/headroomlabs-ai/headroom/commit/d7bc1e275f411788abffa2d007db14aa17fd31c5))
|
||||
* **deps:** bump h2 to 4.4.1 for CVE-2026-71554 ([#2839](https://github.com/headroomlabs-ai/headroom/issues/2839)) ([564e0a8](https://github.com/headroomlabs-ai/headroom/commit/564e0a8d0fe440dff21a6c405c88e05698b3059f))
|
||||
* **deps:** enforce audited transitive dependency floors ([#2791](https://github.com/headroomlabs-ai/headroom/issues/2791)) ([64e2039](https://github.com/headroomlabs-ai/headroom/commit/64e203931b9810e5a010f063d26d154419016f86))
|
||||
* **doctor:** flag `ollama launch claude` proxy bypass instead of misdirecting ([#2566](https://github.com/headroomlabs-ai/headroom/issues/2566)) ([7f24d69](https://github.com/headroomlabs-ai/headroom/commit/7f24d695eea00b9bb3265fbaa6629acf0c2ff181))
|
||||
* emit SSE ping before message_start on Bedrock streaming path (issue [#902](https://github.com/headroomlabs-ai/headroom/issues/902)) ([#1080](https://github.com/headroomlabs-ai/headroom/issues/1080)) ([4dab254](https://github.com/headroomlabs-ai/headroom/commit/4dab254d52914c39ffe13071848604e1771b1bd1))
|
||||
* **gemini:** resolve native CCR retrieval calls ([#2253](https://github.com/headroomlabs-ai/headroom/issues/2253)) ([2483f57](https://github.com/headroomlabs-ai/headroom/commit/2483f570025763cd9183a93749ea8cf38f1aeb85))
|
||||
* **health:** label kompress as degraded/optional when not yet loaded ([#2865](https://github.com/headroomlabs-ai/headroom/issues/2865)) ([8949371](https://github.com/headroomlabs-ai/headroom/commit/89493714d2cffdc1f81a8f417ea09891453d7009))
|
||||
* **image:** decouple routing types from trained_router so importing the compressor doesn't import torch ([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513)) ([#2537](https://github.com/headroomlabs-ai/headroom/issues/2537)) ([d7cf981](https://github.com/headroomlabs-ai/headroom/commit/d7cf981093cf505192a3736dadd0254a120830a1))
|
||||
* **install/windows:** register persistent-task from S4U hidden XML ([#2453](https://github.com/headroomlabs-ai/headroom/issues/2453)) ([#2459](https://github.com/headroomlabs-ai/headroom/issues/2459)) ([1edaeb8](https://github.com/headroomlabs-ai/headroom/commit/1edaeb8b76f6b872a6c810d404c944caf1a594b2))
|
||||
* **install:** don't crash the PowerShell installer when $PROFILE is unset ([#2469](https://github.com/headroomlabs-ai/headroom/issues/2469)) ([fc5c4e2](https://github.com/headroomlabs-ai/headroom/commit/fc5c4e239ce32f2b90a6777772a01bdf49c66cb6))
|
||||
* **install:** trust Docker bridge for dashboard metadata ([e044139](https://github.com/headroomlabs-ai/headroom/commit/e044139001680fd5198147bf373df6f00db32cc7))
|
||||
* **install:** use --userns=keep-id under Podman so bind-mount writes don't fail ([#2846](https://github.com/headroomlabs-ai/headroom/issues/2846)) ([3488f8d](https://github.com/headroomlabs-ai/headroom/commit/3488f8d4b5fae4eab157e0c4031ccf712bcbcc0d))
|
||||
* **learn/gemini:** stop double-counting session tokens ([#2230](https://github.com/headroomlabs-ai/headroom/issues/2230)) ([29d8a5e](https://github.com/headroomlabs-ai/headroom/commit/29d8a5e563cf16dbd3a53a1571f4f352e61e1b33))
|
||||
* **learn/grok:** detect a Windows absolute project path ([#2283](https://github.com/headroomlabs-ai/headroom/issues/2283)) ([e240df2](https://github.com/headroomlabs-ai/headroom/commit/e240df2b698e601324b85956bd93cb304f6030ab))
|
||||
* **learn:** stop classifying a successful exit code 0 as an error ([#2289](https://github.com/headroomlabs-ai/headroom/issues/2289)) ([a24fe7d](https://github.com/headroomlabs-ai/headroom/commit/a24fe7dcbfe5ab30d0cef631c936e2245c12d123))
|
||||
* **litellm:** add async_post_call_success_hook to HeadroomCallback ([#1322](https://github.com/headroomlabs-ai/headroom/issues/1322)) ([3107994](https://github.com/headroomlabs-ai/headroom/commit/3107994aed5fd42e713d3c26f3f08121a62b980e))
|
||||
* **litellm:** don't forward a caller key the target cannot accept ([#2883](https://github.com/headroomlabs-ai/headroom/issues/2883)) ([2f2950a](https://github.com/headroomlabs-ai/headroom/commit/2f2950a626cebf851aac29255e7188fbb1639f5a))
|
||||
* **memory:** bound the TrafficLearner pending-pattern accumulator (memory leak) ([#2579](https://github.com/headroomlabs-ai/headroom/issues/2579)) ([1f5feff](https://github.com/headroomlabs-ai/headroom/commit/1f5fefffd3e82c73bddd928cfd53334031e807bc))
|
||||
* **memory:** close DirectMem0 resources ([6596182](https://github.com/headroomlabs-ai/headroom/commit/65961827cf5e90d7b4e7026feb89aac000a73ea3))
|
||||
* **memory:** close MCP backend on shutdown ([4bd8ecd](https://github.com/headroomlabs-ai/headroom/commit/4bd8ecd1e31475365801791d35630f66f7393553))
|
||||
* **memory:** don't crash inline memory extraction on a non-object <memory> block ([#2470](https://github.com/headroomlabs-ai/headroom/issues/2470)) ([e00c6ff](https://github.com/headroomlabs-ai/headroom/commit/e00c6ff81ce2003e04042b8f2d1bd6aa3c6e885c))
|
||||
* **memory:** keep vector metadata in sync ([#2295](https://github.com/headroomlabs-ai/headroom/issues/2295)) ([c471800](https://github.com/headroomlabs-ai/headroom/commit/c471800e8ee22986c308464b02a85da5575f34cc))
|
||||
* **memory:** make explicit-project and user store keys collision-resistant ([#2231](https://github.com/headroomlabs-ai/headroom/issues/2231)) ([f840d5f](https://github.com/headroomlabs-ai/headroom/commit/f840d5f2fe938432e542c3f71f2218eeecd06b05))
|
||||
* **memory:** skip <system-reminder> blocks when building the retrieval query ([#2195](https://github.com/headroomlabs-ai/headroom/issues/2195)) ([#2541](https://github.com/headroomlabs-ai/headroom/issues/2541)) ([4e5a67a](https://github.com/headroomlabs-ai/headroom/commit/4e5a67a342be4be659b62c7863a9e72422605788))
|
||||
* **memory:** sync FTS5 and vector indexes on CLI delete/edit/prune/purge ([fd4628d](https://github.com/headroomlabs-ai/headroom/commit/fd4628d82156c65d4fa22df9513315790a6cd2fb))
|
||||
* **oauth2:** make repository lint checks pass ([c85abf7](https://github.com/headroomlabs-ai/headroom/commit/c85abf7a87920012e01f0a677f6fbd98c4b08de0))
|
||||
* **observability:** aggregate tool savings in OTEL ([#2936](https://github.com/headroomlabs-ai/headroom/issues/2936)) ([941c25d](https://github.com/headroomlabs-ai/headroom/commit/941c25d31e6c6e0b436c307cbe212771ff76b45f))
|
||||
* **onnx:** stop ONNX thread pools from spinning idle cores ([#2495](https://github.com/headroomlabs-ai/headroom/issues/2495)) ([#2540](https://github.com/headroomlabs-ai/headroom/issues/2540)) ([5c561bd](https://github.com/headroomlabs-ai/headroom/commit/5c561bd913ea60fad2c3c53f4b65e679e7d248d0))
|
||||
* **openai:** skip Responses tool-search deferral for clients that cannot execute it ([#2696](https://github.com/headroomlabs-ai/headroom/issues/2696)) ([54ea28d](https://github.com/headroomlabs-ai/headroom/commit/54ea28d9839a0dcfa4dd0cf4210a4421f03beeff))
|
||||
* **opencode:** ship the transport hook-shim so wheel installs route Node child traffic ([702dbc5](https://github.com/headroomlabs-ai/headroom/commit/702dbc5902ff184a7c20178958a811beb9c78fa3))
|
||||
* **providers/anthropic:** don't crash token estimation on null tool_calls ([#2472](https://github.com/headroomlabs-ai/headroom/issues/2472)) ([08466f3](https://github.com/headroomlabs-ai/headroom/commit/08466f3cae4dbb2647dc6f249fe42c4e840600c5))
|
||||
* **providers/openai:** bound tiktoken vocab loads with the guarded loader ([#2554](https://github.com/headroomlabs-ai/headroom/issues/2554)) ([0805e8e](https://github.com/headroomlabs-ai/headroom/commit/0805e8e410543d75c7ddd3b83dde5eda3bc13144))
|
||||
* **proxy/anthropic:** inject headroom_retrieve whenever a CCR marker is present, not only for new markers ([#2848](https://github.com/headroomlabs-ai/headroom/issues/2848)) ([3808f60](https://github.com/headroomlabs-ai/headroom/commit/3808f60ca61e84faf3ea8f8e003a6e6c8e9af4da))
|
||||
* **proxy/anthropic:** None-guard usage token counts on the direct buffered path ([#2434](https://github.com/headroomlabs-ai/headroom/issues/2434)) ([2b5ee7c](https://github.com/headroomlabs-ai/headroom/commit/2b5ee7cde809ca37f6998d9679b1eb2133ab50ca))
|
||||
* **proxy/anthropic:** run tool-search history repair after turn hooks ([c6f9948](https://github.com/headroomlabs-ai/headroom/commit/c6f99482e1bea024db6014a70c8e6da419543957))
|
||||
* **proxy/batch:** don't crash an OpenAI batch on a valid-JSON non-object line ([#2316](https://github.com/headroomlabs-ai/headroom/issues/2316)) ([1f2c681](https://github.com/headroomlabs-ai/headroom/commit/1f2c681c0b48150a569277d3ebd5e95709dc7c39))
|
||||
* **proxy/bedrock:** report uncached input tokens from backend usage, not the live-zone count ([#2318](https://github.com/headroomlabs-ai/headroom/issues/2318)) ([c19e412](https://github.com/headroomlabs-ai/headroom/commit/c19e412b3356d80dece001887d4ff48b6fd5150b))
|
||||
* **proxy/gemini:** keep streaming-parity baseline so eligible_pct can't exceed 100 ([#2824](https://github.com/headroomlabs-ai/headroom/issues/2824)) ([b97c7c6](https://github.com/headroomlabs-ai/headroom/commit/b97c7c6e99eac84df49c7a7e5f21dedb298716fe))
|
||||
* **proxy/metrics:** cap client-supplied model label cardinality ([#2480](https://github.com/headroomlabs-ai/headroom/issues/2480)) ([e24a7e6](https://github.com/headroomlabs-ai/headroom/commit/e24a7e66b95fa908c4ea6fd079809ece7692e6b2))
|
||||
* **proxy/metrics:** escape label values in the Prometheus export ([#2463](https://github.com/headroomlabs-ai/headroom/issues/2463)) ([6a53861](https://github.com/headroomlabs-ai/headroom/commit/6a53861063c3839e698bbec7194517bdfd851c38))
|
||||
* **proxy/openai:** don't crash the Responses memory tool loops on null arguments ([#2273](https://github.com/headroomlabs-ai/headroom/issues/2273)) ([a30db2c](https://github.com/headroomlabs-ai/headroom/commit/a30db2cae49b4ef03ebbd404ec1fc6c4f5f2404d))
|
||||
* **proxy/openai:** feed Codex WS traffic into the traffic learner ([#2334](https://github.com/headroomlabs-ai/headroom/issues/2334)) ([f669149](https://github.com/headroomlabs-ai/headroom/commit/f6691497692869b7067438597421ff12aace6bf4))
|
||||
* **proxy/openai:** run response hooks on Responses, and bill their re-drives ([#2872](https://github.com/headroomlabs-ai/headroom/issues/2872)) ([675d13f](https://github.com/headroomlabs-ai/headroom/commit/675d13f08d42455c8fa17bda878c1a11b905cee4))
|
||||
* **proxy:** allow settings routes for trusted gateway/dashboard clients ([#2491](https://github.com/headroomlabs-ai/headroom/issues/2491)) ([a5b0a8f](https://github.com/headroomlabs-ai/headroom/commit/a5b0a8f4cc54d68afcf371a422b3a4a9635b7e7f))
|
||||
* **proxy:** cache litellm model resolution to stop repeated Provider List spam ([99f07e7](https://github.com/headroomlabs-ai/headroom/commit/99f07e7bbdded9dadc70e35ee6ab025279d1aa22))
|
||||
* **proxy:** cancel periodic TOIN task on shutdown ([739fdef](https://github.com/headroomlabs-ai/headroom/commit/739fdef423fa8cbc82537481c875d4570b0ecad4))
|
||||
* **proxy:** close the upstream stream when a streaming body is never consumed ([0951663](https://github.com/headroomlabs-ai/headroom/commit/09516635621caccf7e3db4f537eb49ea49b8a453))
|
||||
* **proxy:** compress cache-mode cold starts and tag prefix-mismatch passthrough ([#2365](https://github.com/headroomlabs-ai/headroom/issues/2365)) ([aaeba0a](https://github.com/headroomlabs-ai/headroom/commit/aaeba0a319f12b98cad3bfcf1cf991b694b946bf))
|
||||
* **proxy:** emit request log timestamps in UTC ([620028f](https://github.com/headroomlabs-ai/headroom/commit/620028fa18843622d3e454bd40fb91a93e607dbf))
|
||||
* **proxy:** enable tool search by default and repair poisoned transcripts ([#2807](https://github.com/headroomlabs-ai/headroom/issues/2807)) ([0237cbf](https://github.com/headroomlabs-ai/headroom/commit/0237cbffbbc456ad8a7398005602d76881862d99))
|
||||
* **proxy:** gate mid-turn message coalescing to Claude Code clients ([#1643](https://github.com/headroomlabs-ai/headroom/issues/1643)) ([a4bd2e6](https://github.com/headroomlabs-ai/headroom/commit/a4bd2e62a5bb73f15b3b12e979c69e2b555bee10))
|
||||
* **proxy:** give each Codex /v1/responses WS turn a unique request_id ([#2164](https://github.com/headroomlabs-ai/headroom/issues/2164)) ([d02df10](https://github.com/headroomlabs-ai/headroom/commit/d02df1075894b414d60626aca2bbcadd7a3577a0))
|
||||
* **proxy:** graceful shutdown and reliable Ctrl+C exit ([#621](https://github.com/headroomlabs-ai/headroom/issues/621)) ([17cdb18](https://github.com/headroomlabs-ai/headroom/commit/17cdb185bc79d8cfec104e781a7e555af3ef11e1))
|
||||
* **proxy:** guard telemetry and TOIN endpoints ([cde1513](https://github.com/headroomlabs-ai/headroom/commit/cde1513c91b6c6c240869bc5660f4b8966197bbc))
|
||||
* **proxy:** include tool_search_deferral savings in the savings ledger ([12149f7](https://github.com/headroomlabs-ai/headroom/commit/12149f74466c08b69be8d5fe751425be63c2fda4))
|
||||
* **proxy:** pass through cross-region prefixed Bedrock model IDs directly ([#2330](https://github.com/headroomlabs-ai/headroom/issues/2330)) ([64cb46e](https://github.com/headroomlabs-ai/headroom/commit/64cb46e24bf7b223ea71b14b6f5e86e78fa7ac45))
|
||||
* **proxy:** port session-sticky beta headers to the Rust proxy ([#2381](https://github.com/headroomlabs-ai/headroom/issues/2381)) ([f6398a6](https://github.com/headroomlabs-ai/headroom/commit/f6398a64768a095b722a5fb0b2445c7953dee1c6))
|
||||
* **proxy:** preserve merged session and quarantine contracts ([#2943](https://github.com/headroomlabs-ai/headroom/issues/2943)) ([039cd24](https://github.com/headroomlabs-ai/headroom/commit/039cd2431aaec7d59fefaf7e97aeda1fd7ab3afa))
|
||||
* **proxy:** preserve signed Anthropic thinking blocks on outbound re-serialize ([#2254](https://github.com/headroomlabs-ai/headroom/issues/2254)) ([dc163bc](https://github.com/headroomlabs-ai/headroom/commit/dc163bcd1cba4cd8898f23286eb1365fcf6e0356))
|
||||
* **proxy:** stop discarding compressed Codex WS later-frame payloads ([#2823](https://github.com/headroomlabs-ai/headroom/issues/2823)) ([4ec416d](https://github.com/headroomlabs-ai/headroom/commit/4ec416df8899036544e679f561f1cf921f3da0dd))
|
||||
* **proxy:** time-cap the compression timeout-debt quarantine ([#2360](https://github.com/headroomlabs-ai/headroom/issues/2360)) ([#2412](https://github.com/headroomlabs-ai/headroom/issues/2412)) ([c5a08d2](https://github.com/headroomlabs-ai/headroom/commit/c5a08d22e05a7dd2b929f3cca76ee3fb42f122db))
|
||||
* **proxy:** unwrap Hermes tool_call bridge in tool name map ([#2717](https://github.com/headroomlabs-ai/headroom/issues/2717)) ([a97b824](https://github.com/headroomlabs-ai/headroom/commit/a97b82413bdc86655c064417ed4628ff4d9d7c9d))
|
||||
* publish headroom-opencode in release workflow ([#2372](https://github.com/headroomlabs-ai/headroom/issues/2372)) ([7859154](https://github.com/headroomlabs-ai/headroom/commit/78591545ceb8303fdf9b93cd5ff02b626df97d2b))
|
||||
* **settings:** accept documented HEADROOM_* env names as settings keys ([#2833](https://github.com/headroomlabs-ai/headroom/issues/2833)) ([de9e052](https://github.com/headroomlabs-ai/headroom/commit/de9e0523dad47b700062464adecd60f82547f332))
|
||||
* **subscription:** dedup transcript usage by message id ([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340) token inflation) ([#2408](https://github.com/headroomlabs-ai/headroom/issues/2408)) ([74275b7](https://github.com/headroomlabs-ai/headroom/commit/74275b7c3e2b39be5198f9efa35057a5e026e665))
|
||||
* **toin:** bound private query and pattern retention ([8cd1380](https://github.com/headroomlabs-ai/headroom/commit/8cd138039edbfc295080ec474325d527fb3aedf3))
|
||||
* **tokenizer:** coerce non-string tool_call fields before counting ([#2801](https://github.com/headroomlabs-ai/headroom/issues/2801)) ([b6f9877](https://github.com/headroomlabs-ai/headroom/commit/b6f9877c78b3fa3b1d705426bd27d74be77f4fa0))
|
||||
* **tokenizer:** price CJK in the Rust fixed-ratio estimator (Python parity) ([#2260](https://github.com/headroomlabs-ai/headroom/issues/2260)) ([6840153](https://github.com/headroomlabs-ai/headroom/commit/6840153473caa0d61e982215e16a8cf54b0b6cc7))
|
||||
* **transforms/adaptive-sizer:** honor max_k on small-input fast path ([#2319](https://github.com/headroomlabs-ai/headroom/issues/2319)) ([8a90523](https://github.com/headroomlabs-ai/headroom/commit/8a905232091d993fac9e19a59bc449f201d4cdf3))
|
||||
* **transforms/smart_crusher:** don't crash on a tool call with a null function ([#2232](https://github.com/headroomlabs-ai/headroom/issues/2232)) ([3bb02f8](https://github.com/headroomlabs-ai/headroom/commit/3bb02f8f75f12cf8258a5b1c2a7fbdc190f9d074))
|
||||
* Vertex model pricing shows $0.00 for versioned model names and vertex:anthropic provider ([#2517](https://github.com/headroomlabs-ai/headroom/issues/2517)) ([eb5b5e4](https://github.com/headroomlabs-ai/headroom/commit/eb5b5e41988f5c27d29ae8ae3e5fe74e56493b8c))
|
||||
* **wrap/claude:** keep --1m effective when an explicit --model is passed through ([c093bf1](https://github.com/headroomlabs-ai/headroom/commit/c093bf11eb5f356f71367ebb7b56ae3c2b434a12))
|
||||
* **wrap/opencode:** verify the opencode binary before mutating config ([ae38486](https://github.com/headroomlabs-ai/headroom/commit/ae384862a4950cec057103e9daf75e74107640df))
|
||||
* **wrap/serena:** install Serena from the serena-agent PyPI wheel, not the git source ([d7b25ae](https://github.com/headroomlabs-ai/headroom/commit/d7b25ae3bb3364cde4931509ecb65e32085e5b09))
|
||||
* **wrap:** honor Copilot OAuth wire-api override and model default ([#2387](https://github.com/headroomlabs-ai/headroom/issues/2387)) ([1db6d88](https://github.com/headroomlabs-ai/headroom/commit/1db6d88ab4ea25654b8277358902b7df700db6b4))
|
||||
* **wrap:** serialize shared proxy startup ([#2946](https://github.com/headroomlabs-ai/headroom/issues/2946)) ([e540d64](https://github.com/headroomlabs-ai/headroom/commit/e540d64febf27f2e7997d3a1a1d89478cc1ef658))
|
||||
* **wrap:** stop the launch cwd from shadowing the installed package in the proxy subprocess ([#2843](https://github.com/headroomlabs-ai/headroom/issues/2843)) ([c49be26](https://github.com/headroomlabs-ai/headroom/commit/c49be269a18446779cd8a048caaa7f0ba3a3b48b))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cut hot-path latency 27% (token-count memo, startup preloads, JSON scan memo) ([#2838](https://github.com/headroomlabs-ai/headroom/issues/2838)) ([53af90d](https://github.com/headroomlabs-ai/headroom/commit/53af90d68c723f644a5a41dd273a606117109866))
|
||||
* **proxy:** bound upstream calls and hot-path costs ([#2852](https://github.com/headroomlabs-ai/headroom/issues/2852)) ([f624d3a](https://github.com/headroomlabs-ai/headroom/commit/f624d3a00ac271db7947443ddeb0c8bc2e93d3eb))
|
||||
* **subscription:** skip transcripts older than the window in compute_window_tokens ([#2861](https://github.com/headroomlabs-ai/headroom/issues/2861)) ([91d6bf3](https://github.com/headroomlabs-ai/headroom/commit/91d6bf33cde777b541375fb182d4479fdd78f81b))
|
||||
|
||||
|
||||
### Dependencies
|
||||
|
||||
* bump brace-expansion from 5.0.7 to 5.0.9 in /docs ([#2751](https://github.com/headroomlabs-ai/headroom/issues/2751)) ([56ee57b](https://github.com/headroomlabs-ai/headroom/commit/56ee57be98bf109f0a46de522724ef169a4bc51c))
|
||||
* bump bytesize from 1.3.3 to 2.4.2 ([#2286](https://github.com/headroomlabs-ai/headroom/issues/2286)) ([6448545](https://github.com/headroomlabs-ai/headroom/commit/6448545a7f5a1dee88bce6f0830bdbfd1c99c617))
|
||||
* bump hf-hub from 0.4.3 to 0.5.0 ([#2285](https://github.com/headroomlabs-ai/headroom/issues/2285)) ([4925bf6](https://github.com/headroomlabs-ai/headroom/commit/4925bf6a829735977bab5000b469c3edb19c75b1))
|
||||
* bump next from 16.2.10 to 16.3.0 in /docs ([#2750](https://github.com/headroomlabs-ai/headroom/issues/2750)) ([0fd0b99](https://github.com/headroomlabs-ai/headroom/commit/0fd0b996a4b58a166491b145f4d3885c21b27cc0))
|
||||
* bump postcss from 8.5.19 to 8.5.25 in /plugins/openclaw ([#2749](https://github.com/headroomlabs-ai/headroom/issues/2749)) ([cd60ee9](https://github.com/headroomlabs-ai/headroom/commit/cd60ee9ae886b32ba5da3203e35bb6b088031fd3))
|
||||
* bump postcss from 8.5.19 to 8.5.25 in /plugins/opencode ([#2748](https://github.com/headroomlabs-ai/headroom/issues/2748)) ([ff4e016](https://github.com/headroomlabs-ai/headroom/commit/ff4e0167bbccbd4ae51bf23ddec144e61c94cd68))
|
||||
* bump postcss from 8.5.19 to 8.5.25 in /sdk/typescript ([#2747](https://github.com/headroomlabs-ai/headroom/issues/2747)) ([267c2bd](https://github.com/headroomlabs-ai/headroom/commit/267c2bdcb56e132b2dd9c065dab3498dbf730ca3))
|
||||
* bump postcss from 8.5.19 to 8.5.26 in /docs ([#2881](https://github.com/headroomlabs-ai/headroom/issues/2881)) ([e6e5826](https://github.com/headroomlabs-ai/headroom/commit/e6e5826423a0a700a8c544ce2c8cbcdef694160e))
|
||||
* bump ruff from 0.15.17 to 0.15.22 in the pip-minor-patch group ([#2501](https://github.com/headroomlabs-ai/headroom/issues/2501)) ([ecf130d](https://github.com/headroomlabs-ai/headroom/commit/ecf130d3ac6fb864098cb93fafd2621ae3ac7e12))
|
||||
* bump rusqlite from 0.32.1 to 0.40.1 ([#2287](https://github.com/headroomlabs-ai/headroom/issues/2287)) ([522faa1](https://github.com/headroomlabs-ai/headroom/commit/522faa1a59aa94e4adfd4a4afe0202d1126e187d))
|
||||
* bump the cargo-minor-patch group across 1 directory with 22 updates ([#2916](https://github.com/headroomlabs-ai/headroom/issues/2916)) ([148d860](https://github.com/headroomlabs-ai/headroom/commit/148d8605e2087f3c8d6a3fa4b8d248ad2da5858f))
|
||||
|
||||
## [0.34.0](https://github.com/headroomlabs-ai/headroom/compare/v0.33.0...v0.34.0) (2026-08-05)
|
||||
|
||||
|
||||
|
|
|
|||
665
Cargo.lock
generated
665
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -58,7 +58,7 @@ tracing = { version = "0.1", features = ["log"] }
|
|||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
axum = "0.7"
|
||||
axum = "0.8"
|
||||
tower = "0.5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
pyo3 = { version = "0.29", features = ["abi3-py310"] }
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
|||
COPY crates/ crates/
|
||||
COPY headroom/ headroom/
|
||||
|
||||
ARG HEADROOM_EXTRAS=proxy,code
|
||||
# The standalone Dockerfile must support every backend advertised by
|
||||
# `headroom proxy --backend`, including Bedrock temporary/SSO credentials.
|
||||
# Those credentials require botocore (GH #1551), supplied by [bedrock].
|
||||
ARG HEADROOM_EXTRAS=proxy,code,bedrock
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
|
|
|
|||
|
|
@ -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+**.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub mod compression_policy;
|
|||
#[cfg(feature = "ml")]
|
||||
mod onnx_cpu;
|
||||
pub mod relevance;
|
||||
pub mod rollout;
|
||||
pub mod signals;
|
||||
pub mod tokenizer;
|
||||
pub mod transforms;
|
||||
|
|
|
|||
444
crates/headroom-core/src/rollout.rs
Normal file
444
crates/headroom-core/src/rollout.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
//! Deterministic runtime-rollout policy and provenance.
|
||||
//!
|
||||
//! Rollout channels control behavior in an already-built artifact. They do not
|
||||
//! select a package, release candidate, or distribution version. Composition
|
||||
//! roots resolve one immutable snapshot and inject its concrete decisions.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub const ROLLOUT_SCHEMA_VERSION: u32 = 1;
|
||||
pub const ROLLOUT_POLICY_VERSION: &str = "1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RolloutChannel {
|
||||
#[default]
|
||||
Stable,
|
||||
Beta,
|
||||
Canary,
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl RolloutChannel {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Beta => "beta",
|
||||
Self::Canary => "canary",
|
||||
Self::Dev => "dev",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(self, required: Self) -> bool {
|
||||
self >= required
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RolloutChannel {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"" | "stable" | "prod" | "production" => Ok(Self::Stable),
|
||||
"beta" | "preview" => Ok(Self::Beta),
|
||||
"canary" | "nightly" => Ok(Self::Canary),
|
||||
"dev" | "development" => Ok(Self::Dev),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Feature {
|
||||
NativeBedrock,
|
||||
OpenAiResponsesStreaming,
|
||||
CanaryProbe,
|
||||
}
|
||||
|
||||
const ALL_FEATURES: [Feature; 3] = [
|
||||
Feature::CanaryProbe,
|
||||
Feature::NativeBedrock,
|
||||
Feature::OpenAiResponsesStreaming,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct FeatureSpec {
|
||||
pub name: &'static str,
|
||||
pub available_in: RolloutChannel,
|
||||
pub default_enabled_in: Option<RolloutChannel>,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
pub fn spec(self) -> FeatureSpec {
|
||||
match self {
|
||||
Self::NativeBedrock => FeatureSpec {
|
||||
name: "native_bedrock",
|
||||
available_in: RolloutChannel::Stable,
|
||||
default_enabled_in: Some(RolloutChannel::Stable),
|
||||
},
|
||||
Self::OpenAiResponsesStreaming => FeatureSpec {
|
||||
name: "openai_responses_streaming",
|
||||
available_in: RolloutChannel::Stable,
|
||||
default_enabled_in: Some(RolloutChannel::Stable),
|
||||
},
|
||||
Self::CanaryProbe => FeatureSpec {
|
||||
name: "canary_probe",
|
||||
available_in: RolloutChannel::Canary,
|
||||
default_enabled_in: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureDecisionReason {
|
||||
Default,
|
||||
Explicit,
|
||||
LegacyAlias,
|
||||
Disabled,
|
||||
BlockedByChannel,
|
||||
UnsafeOverride,
|
||||
NotRequested,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct RolloutConfig {
|
||||
pub channel: RolloutChannel,
|
||||
pub requested: BTreeSet<String>,
|
||||
pub disabled: BTreeSet<String>,
|
||||
pub unsafe_allow_unstable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct FeatureDecision {
|
||||
pub name: &'static str,
|
||||
pub available_in: RolloutChannel,
|
||||
pub default_enabled_in: Option<RolloutChannel>,
|
||||
pub requested: bool,
|
||||
pub disabled: bool,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "decision")]
|
||||
pub reason: FeatureDecisionReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RolloutSnapshot {
|
||||
pub schema_version: u32,
|
||||
pub policy_version: &'static str,
|
||||
pub registry_digest: String,
|
||||
pub config: RolloutConfig,
|
||||
pub decisions: Vec<FeatureDecision>,
|
||||
}
|
||||
|
||||
impl Default for RolloutSnapshot {
|
||||
fn default() -> Self {
|
||||
Self::from_parts("stable", "", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutSnapshot {
|
||||
pub fn from_parts(
|
||||
channel: &str,
|
||||
requested: &str,
|
||||
disabled: &str,
|
||||
unsafe_allow_unstable: bool,
|
||||
) -> Self {
|
||||
Self::from_parts_with_explicit(channel, requested, disabled, unsafe_allow_unstable, &[])
|
||||
}
|
||||
|
||||
pub fn from_parts_with_explicit(
|
||||
channel: &str,
|
||||
requested: &str,
|
||||
disabled: &str,
|
||||
unsafe_allow_unstable: bool,
|
||||
explicit: &[Feature],
|
||||
) -> Self {
|
||||
let parsed_channel = RolloutChannel::from_str(channel).unwrap_or_else(|_| {
|
||||
tracing::warn!(channel, "unknown rollout channel; falling back to stable");
|
||||
RolloutChannel::Stable
|
||||
});
|
||||
let valid_names: BTreeSet<_> = ALL_FEATURES
|
||||
.iter()
|
||||
.map(|feature| feature.spec().name.to_owned())
|
||||
.collect();
|
||||
let mut requested_names = validated_names(requested, "requested", &valid_names);
|
||||
requested_names.extend(
|
||||
explicit
|
||||
.iter()
|
||||
.map(|feature| feature.spec().name.to_owned()),
|
||||
);
|
||||
let disabled_names = validated_names(disabled, "disabled", &valid_names);
|
||||
let config = RolloutConfig {
|
||||
channel: parsed_channel,
|
||||
requested: requested_names,
|
||||
disabled: disabled_names,
|
||||
unsafe_allow_unstable,
|
||||
};
|
||||
let decisions = ALL_FEATURES
|
||||
.iter()
|
||||
.map(|feature| resolve_feature(*feature, &config))
|
||||
.collect();
|
||||
Self {
|
||||
schema_version: ROLLOUT_SCHEMA_VERSION,
|
||||
policy_version: ROLLOUT_POLICY_VERSION,
|
||||
registry_digest: registry_digest(),
|
||||
config,
|
||||
decisions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision(&self, feature: Feature) -> &FeatureDecision {
|
||||
let name = feature.spec().name;
|
||||
self.decisions
|
||||
.iter()
|
||||
.find(|decision| decision.name == name)
|
||||
.expect("every registered feature has a decision")
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self, feature: Feature, _explicit: bool) -> bool {
|
||||
self.decision(feature).enabled
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> BTreeSet<String> {
|
||||
self.decisions
|
||||
.iter()
|
||||
.filter(|decision| decision.enabled)
|
||||
.map(|decision| decision.name.to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn qualification_eligible(&self) -> bool {
|
||||
!self.config.unsafe_allow_unstable
|
||||
}
|
||||
|
||||
fn canonical_value(&self) -> Value {
|
||||
json!({
|
||||
"schema_version": self.schema_version,
|
||||
"policy_version": self.policy_version,
|
||||
"channel": self.config.channel,
|
||||
"unsafe_override": self.config.unsafe_allow_unstable,
|
||||
"registry_digest": self.registry_digest,
|
||||
"features": self.decisions,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot_digest(&self) -> String {
|
||||
digest_value(&self.canonical_value())
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> Value {
|
||||
let mut value = self.canonical_value();
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.expect("rollout snapshot is an object");
|
||||
object.insert("snapshot_digest".into(), json!(self.snapshot_digest()));
|
||||
object.insert(
|
||||
"qualification_eligible".into(),
|
||||
json!(self.qualification_eligible()),
|
||||
);
|
||||
if !self.qualification_eligible() {
|
||||
object.insert(
|
||||
"qualification_ineligible_reason".into(),
|
||||
json!("unsafe_rollout_override_active"),
|
||||
);
|
||||
}
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_feature(feature: Feature, config: &RolloutConfig) -> FeatureDecision {
|
||||
let spec = feature.spec();
|
||||
let requested = config.requested.contains(spec.name);
|
||||
let disabled = config.disabled.contains(spec.name);
|
||||
let normally_available = config.channel.allows(spec.available_in);
|
||||
let (enabled, reason) = if disabled {
|
||||
(false, FeatureDecisionReason::Disabled)
|
||||
} else if requested && !normally_available && !config.unsafe_allow_unstable {
|
||||
(false, FeatureDecisionReason::BlockedByChannel)
|
||||
} else if requested && !normally_available {
|
||||
(true, FeatureDecisionReason::UnsafeOverride)
|
||||
} else if requested {
|
||||
(true, FeatureDecisionReason::Explicit)
|
||||
} else if spec
|
||||
.default_enabled_in
|
||||
.is_some_and(|minimum| config.channel.allows(minimum))
|
||||
{
|
||||
(true, FeatureDecisionReason::Default)
|
||||
} else {
|
||||
(false, FeatureDecisionReason::NotRequested)
|
||||
};
|
||||
FeatureDecision {
|
||||
name: spec.name,
|
||||
available_in: spec.available_in,
|
||||
default_enabled_in: spec.default_enabled_in,
|
||||
requested,
|
||||
disabled,
|
||||
enabled,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn validated_names(raw: &str, source: &str, valid: &BTreeSet<String>) -> BTreeSet<String> {
|
||||
let names: BTreeSet<_> = split_feature_names(raw).into_iter().collect();
|
||||
for unknown in names.difference(valid) {
|
||||
tracing::warn!(
|
||||
feature = unknown,
|
||||
source,
|
||||
"unknown rollout feature; ignoring (fail-closed)"
|
||||
);
|
||||
}
|
||||
names.intersection(valid).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn split_feature_names(raw: &str) -> Vec<String> {
|
||||
raw.replace(';', ",")
|
||||
.split(',')
|
||||
.filter_map(|part| {
|
||||
let normalized = normalize_feature_name(part);
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn normalize_feature_name(raw: impl AsRef<str>) -> String {
|
||||
raw.as_ref().trim().to_ascii_lowercase().replace('-', "_")
|
||||
}
|
||||
|
||||
pub fn registry_digest() -> String {
|
||||
let registry: Vec<_> = ALL_FEATURES.iter().map(|feature| feature.spec()).collect();
|
||||
digest_value(&serde_json::to_value(registry).expect("registry is serializable"))
|
||||
}
|
||||
|
||||
pub fn feature_names() -> BTreeSet<&'static str> {
|
||||
ALL_FEATURES
|
||||
.iter()
|
||||
.map(|feature| feature.spec().name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn digest_value(value: &Value) -> String {
|
||||
let canonical = serde_json::to_vec(value).expect("rollout provenance is serializable");
|
||||
let digest = Sha256::digest(canonical);
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
hex.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
format!("sha256:{hex}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PolicyVector {
|
||||
channel: String,
|
||||
requested: bool,
|
||||
disabled: bool,
|
||||
#[serde(rename = "unsafe")]
|
||||
unsafe_override: bool,
|
||||
enabled: bool,
|
||||
decision: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_order_matches_python_policy() {
|
||||
assert!(RolloutChannel::Dev.allows(RolloutChannel::Canary));
|
||||
assert!(RolloutChannel::Canary.allows(RolloutChannel::Beta));
|
||||
assert!(!RolloutChannel::Stable.allows(RolloutChannel::Canary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_blocks_explicit_canary_feature_with_reason() {
|
||||
let rollout = RolloutSnapshot::from_parts("stable", "canary_probe", "", false);
|
||||
let decision = rollout.decision(Feature::CanaryProbe);
|
||||
assert!(!decision.enabled);
|
||||
assert_eq!(decision.reason, FeatureDecisionReason::BlockedByChannel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_enabled_feature_has_default_reason() {
|
||||
let rollout = RolloutSnapshot::default();
|
||||
let decision = rollout.decision(Feature::NativeBedrock);
|
||||
assert!(decision.enabled);
|
||||
assert_eq!(decision.reason, FeatureDecisionReason::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_override_crosses_boundary_and_is_ineligible() {
|
||||
let rollout = RolloutSnapshot::from_parts("stable", "canary_probe", "", true);
|
||||
assert_eq!(
|
||||
rollout.decision(Feature::CanaryProbe).reason,
|
||||
FeatureDecisionReason::UnsafeOverride
|
||||
);
|
||||
assert!(!rollout.qualification_eligible());
|
||||
assert_eq!(
|
||||
rollout.to_value()["qualification_ineligible_reason"],
|
||||
"unsafe_rollout_override_active"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_beats_default_explicit_and_unsafe() {
|
||||
for unsafe_override in [false, true] {
|
||||
let rollout = RolloutSnapshot::from_parts(
|
||||
"stable",
|
||||
"native_bedrock",
|
||||
"native-bedrock",
|
||||
unsafe_override,
|
||||
);
|
||||
assert_eq!(
|
||||
rollout.decision(Feature::NativeBedrock).reason,
|
||||
FeatureDecisionReason::Disabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_digests_are_deterministic_and_policy_sensitive() {
|
||||
let first = RolloutSnapshot::from_parts("canary", "canary_probe", "", false);
|
||||
let second = RolloutSnapshot::from_parts("canary", "canary_probe", "", false);
|
||||
let changed = RolloutSnapshot::from_parts("stable", "canary_probe", "", false);
|
||||
assert_eq!(first.registry_digest, second.registry_digest);
|
||||
assert_eq!(first.snapshot_digest(), second.snapshot_digest());
|
||||
assert_ne!(first.snapshot_digest(), changed.snapshot_digest());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inputs_fail_closed() {
|
||||
let rollout = RolloutSnapshot::from_parts("stabel", "unknown", "unknown", false);
|
||||
assert_eq!(rollout.config.channel, RolloutChannel::Stable);
|
||||
assert!(rollout.config.requested.is_empty());
|
||||
assert!(rollout.config.disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_python_rust_policy_vectors() {
|
||||
let vectors: Vec<PolicyVector> = serde_json::from_str(include_str!(
|
||||
"../../../tests/fixtures/rollout_policy_vectors.json"
|
||||
))
|
||||
.unwrap();
|
||||
for vector in vectors {
|
||||
let requested = if vector.requested { "canary_probe" } else { "" };
|
||||
let disabled = if vector.disabled { "canary_probe" } else { "" };
|
||||
let rollout = RolloutSnapshot::from_parts(
|
||||
&vector.channel,
|
||||
requested,
|
||||
disabled,
|
||||
vector.unsafe_override,
|
||||
);
|
||||
let decision = rollout.decision(Feature::CanaryProbe);
|
||||
assert_eq!(decision.enabled, vector.enabled);
|
||||
assert_eq!(
|
||||
serde_json::to_value(decision.reason).unwrap(),
|
||||
vector.decision
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ pub async fn healthz() -> impl IntoResponse {
|
|||
Json(json!({ "ok": true, "service": "headroom-proxy" }))
|
||||
}
|
||||
|
||||
/// Effective rollout state of this running Rust proxy process.
|
||||
pub async fn rollout_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(state.config.rollout.to_value())
|
||||
}
|
||||
|
||||
/// Upstream health: GETs upstream `/healthz`. Returns 200 when reachable +
|
||||
/// 2xx, 503 otherwise. The endpoint name is reserved by the proxy and is
|
||||
/// not forwarded; operators must not name a real upstream route this.
|
||||
|
|
@ -39,3 +44,18 @@ pub async fn healthz_upstream(State(state): State<AppState>) -> Response {
|
|||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Config;
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollout_status_exposes_running_snapshot() {
|
||||
let state = AppState::new(Config::for_test("http://127.0.0.1:9".parse().unwrap())).unwrap();
|
||||
let expected = state.config.rollout.snapshot_digest();
|
||||
let Json(payload) = rollout_status(State(state)).await;
|
||||
assert_eq!(payload["snapshot_digest"], expected);
|
||||
assert_eq!(payload["qualification_eligible"], true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,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"
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -218,10 +218,10 @@ async fn run_ws_pump(
|
|||
|
||||
fn ax_to_tg(m: AxMsg) -> Option<TgMsg> {
|
||||
Some(match m {
|
||||
AxMsg::Text(t) => TgMsg::Text(t.to_string()),
|
||||
AxMsg::Binary(b) => TgMsg::Binary(b.to_vec()),
|
||||
AxMsg::Ping(p) => TgMsg::Ping(p.to_vec()),
|
||||
AxMsg::Pong(p) => TgMsg::Pong(p.to_vec()),
|
||||
AxMsg::Text(t) => TgMsg::Text(t.to_string().into()),
|
||||
AxMsg::Binary(b) => TgMsg::Binary(b.to_vec().into()),
|
||||
AxMsg::Ping(p) => TgMsg::Ping(p.to_vec().into()),
|
||||
AxMsg::Pong(p) => TgMsg::Pong(p.to_vec().into()),
|
||||
AxMsg::Close(Some(cf)) => TgMsg::Close(Some(TgCloseFrame {
|
||||
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(cf.code),
|
||||
reason: cf.reason.to_string().into(),
|
||||
|
|
@ -232,10 +232,10 @@ fn ax_to_tg(m: AxMsg) -> Option<TgMsg> {
|
|||
|
||||
fn tg_to_ax(m: TgMsg) -> Option<AxMsg> {
|
||||
Some(match m {
|
||||
TgMsg::Text(t) => AxMsg::Text(t.as_str().to_string()),
|
||||
TgMsg::Binary(b) => AxMsg::Binary(b.to_vec()),
|
||||
TgMsg::Ping(p) => AxMsg::Ping(p.to_vec()),
|
||||
TgMsg::Pong(p) => AxMsg::Pong(p.to_vec()),
|
||||
TgMsg::Text(t) => AxMsg::Text(t.as_str().to_string().into()),
|
||||
TgMsg::Binary(b) => AxMsg::Binary(b.to_vec().into()),
|
||||
TgMsg::Ping(p) => AxMsg::Ping(p.to_vec().into()),
|
||||
TgMsg::Pong(p) => AxMsg::Pong(p.to_vec().into()),
|
||||
TgMsg::Close(Some(cf)) => AxMsg::Close(Some(CloseFrame {
|
||||
code: cf.code.into(),
|
||||
reason: cf.reason.to_string().into(),
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ async fn bedrock_classified_as_oauth() {
|
|||
auth_mode.as_str().to_string()
|
||||
}
|
||||
let app = Router::new()
|
||||
.route("/model/:model_id/invoke", post(probe))
|
||||
.route("/model/{model_id}/invoke", post(probe))
|
||||
.route_layer(axum::middleware::from_fn(classify_and_attach_auth_mode));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ target "runtime-default" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy"
|
||||
HEADROOM_EXTRAS = "proxy,bedrock"
|
||||
RUNTIME_USER = "nonroot"
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ target "runtime" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy"
|
||||
HEADROOM_EXTRAS = "proxy,bedrock"
|
||||
RUNTIME_USER = "root"
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ target "runtime-nonroot" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy"
|
||||
HEADROOM_EXTRAS = "proxy,bedrock"
|
||||
RUNTIME_USER = "nonroot"
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ target "runtime-code" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy,code"
|
||||
HEADROOM_EXTRAS = "proxy,code,bedrock"
|
||||
RUNTIME_USER = "root"
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ target "runtime-code-nonroot" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy,code"
|
||||
HEADROOM_EXTRAS = "proxy,code,bedrock"
|
||||
RUNTIME_USER = "nonroot"
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ target "runtime-slim" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime-slim"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy"
|
||||
HEADROOM_EXTRAS = "proxy,bedrock"
|
||||
RUNTIME_USER = "root"
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ target "runtime-slim-nonroot" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime-slim"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy"
|
||||
HEADROOM_EXTRAS = "proxy,bedrock"
|
||||
RUNTIME_USER = "nonroot"
|
||||
}
|
||||
}
|
||||
|
|
@ -73,7 +73,7 @@ target "runtime-code-slim" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime-slim"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy,code"
|
||||
HEADROOM_EXTRAS = "proxy,code,bedrock"
|
||||
RUNTIME_USER = "root"
|
||||
}
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ target "runtime-code-slim-nonroot" {
|
|||
inherits = ["_common", "docker-metadata-action"]
|
||||
target = "runtime-slim"
|
||||
args = {
|
||||
HEADROOM_EXTRAS = "proxy,code"
|
||||
HEADROOM_EXTRAS = "proxy,code,bedrock"
|
||||
RUNTIME_USER = "nonroot"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"architecture",
|
||||
"ci-cd-flows",
|
||||
"releases",
|
||||
"runtime-rollouts",
|
||||
"benchmarks",
|
||||
"limitations",
|
||||
"---Help---",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`). |
|
||||
|
||||
|
|
|
|||
174
docs/content/docs/runtime-rollouts.mdx
Normal file
174
docs/content/docs/runtime-rollouts.mdx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
title: Runtime Rollouts
|
||||
description: Deterministic runtime feature control for installed Headroom artifacts.
|
||||
---
|
||||
|
||||
Runtime rollout answers one question: **which behaviors may this already-built
|
||||
Headroom artifact expose in this process?** It is separate from the source and
|
||||
distribution lifecycle, which decides which commit/artifact is qualified,
|
||||
released, packaged, and published.
|
||||
|
||||
```bash
|
||||
HEADROOM_ROLLOUT_CHANNEL=canary headroom proxy
|
||||
```
|
||||
|
||||
This runs the installed artifact with canary-eligible runtime features available
|
||||
according to that artifact's rollout policy. It does **not** install, select, or
|
||||
run a canary release/version of Headroom.
|
||||
|
||||
## Channels and feature policy
|
||||
|
||||
Channels are ordered `stable < beta < canary < dev`.
|
||||
|
||||
| Channel | Purpose |
|
||||
|---------|---------|
|
||||
| `stable` | Default; behavior eligible for normal production use. |
|
||||
| `beta` | Opt-in behavior backed by automated and limited production evidence. |
|
||||
| `canary` | Early dogfood behavior still gathering evidence. |
|
||||
| `dev` | Local development and maintainer experiments. |
|
||||
|
||||
Availability and default enablement are separate registry fields. A feature can
|
||||
be available in `canary` but remain off until explicitly requested; another can
|
||||
be available and default-enabled in `stable`.
|
||||
|
||||
Request a named feature:
|
||||
|
||||
```bash
|
||||
HEADROOM_ROLLOUT_CHANNEL=canary \
|
||||
HEADROOM_FEATURES=tool_result_interceptors \
|
||||
headroom proxy --intercept-tool-results
|
||||
```
|
||||
|
||||
Force it off with the kill switch:
|
||||
|
||||
```bash
|
||||
HEADROOM_DISABLE_FEATURES=tool_result_interceptors headroom proxy
|
||||
```
|
||||
|
||||
## Resolution and precedence
|
||||
|
||||
CLI arguments, environment variables, and typed configuration are resolved once
|
||||
at configuration construction. The immutable snapshot is injected into the
|
||||
proxy and transform pipelines; changing the process environment afterward does
|
||||
not alter a running proxy.
|
||||
|
||||
The existing loopback-only `/admin/runtime-env` endpoint is one narrow
|
||||
exception: hot-reloading the legacy `HEADROOM_OUTPUT_SHAPER` alias replaces the
|
||||
proxy's immutable snapshot with a newly resolved snapshot. Channel bounds and
|
||||
`HEADROOM_DISABLE_FEATURES` still win, and `/stats.rollout` changes with the
|
||||
effective running decision. Because these overrides are process-local, the
|
||||
endpoint rejects updates when the built-in server uses multiple workers; restart
|
||||
the proxy with the desired environment instead. Ambient environment mutation
|
||||
remains ignored.
|
||||
|
||||
Precedence is deterministic:
|
||||
|
||||
| Condition | Result |
|
||||
|-----------|--------|
|
||||
| Explicit disable | Off, even if defaulted, requested, aliased, or unsafe override is active. |
|
||||
| Requested below its availability channel, unsafe override active | On with `unsafe_override`. |
|
||||
| Requested below its availability channel | Off with `blocked_by_channel`. |
|
||||
| Explicit request in an allowed channel | On with `explicit`. |
|
||||
| Enabled legacy alias in an allowed channel | On with `legacy_alias`. |
|
||||
| Default-enabled in the active channel | On with `default`. |
|
||||
| Otherwise | Off with `not_requested`. |
|
||||
|
||||
Legacy feature-specific variables are narrow compatibility aliases only. They
|
||||
obey channel bounds and explicit disable precedence.
|
||||
|
||||
## Unsafe override and invalid input
|
||||
|
||||
`HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1` is a break-glass mechanism. It can
|
||||
cross a channel boundary for a requested feature, but cannot beat an explicit
|
||||
disable. The runtime remains usable for debugging and emergency reproduction,
|
||||
while its snapshot reports:
|
||||
|
||||
```json
|
||||
{
|
||||
"unsafe_override": true,
|
||||
"qualification_eligible": false,
|
||||
"qualification_ineligible_reason": "unsafe_rollout_override_active"
|
||||
}
|
||||
```
|
||||
|
||||
The Python resolver logs a warning and falls back to `stable` for an unknown
|
||||
channel; unknown feature names are warned and ignored (fail-closed). Explicit
|
||||
Python diagnostics (`headroom rollout status`) and the Rust front proxy's typed
|
||||
CLI/environment parser reject unknown channels/features and list valid values
|
||||
before startup.
|
||||
|
||||
## Machine-readable status and provenance
|
||||
|
||||
Inspect a supplied configuration without starting the proxy:
|
||||
|
||||
```bash
|
||||
headroom rollout status --json
|
||||
```
|
||||
|
||||
Inspect the actual running process through the supported black-box endpoint:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/stats
|
||||
```
|
||||
|
||||
The Python proxy publishes the object at `/stats.rollout`. The Rust front proxy,
|
||||
when deployed, publishes its own effective snapshot at `/rollout/status`; this
|
||||
keeps each process's distinct feature registry and decisions independently
|
||||
observable.
|
||||
|
||||
The `/stats.rollout` object and CLI output contain no secrets. They include:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"policy_version": "1",
|
||||
"channel": "stable",
|
||||
"unsafe_override": false,
|
||||
"registry_digest": "sha256:...",
|
||||
"snapshot_digest": "sha256:...",
|
||||
"qualification_eligible": true,
|
||||
"features": [
|
||||
{
|
||||
"name": "tool_result_interceptors",
|
||||
"available_in": "canary",
|
||||
"default_enabled_in": null,
|
||||
"requested": false,
|
||||
"disabled": false,
|
||||
"enabled": false,
|
||||
"decision": "not_requested"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`schema_version` versions the external JSON contract. `policy_version` versions
|
||||
the rollout rules. `registry_digest` is SHA-256 over canonical, ordered feature
|
||||
definitions. `snapshot_digest` identifies the complete effective runtime state.
|
||||
Equivalent policies/configurations produce equal digests; material policy or
|
||||
decision changes do not.
|
||||
|
||||
These identities deliberately remain separate from source SHA, artifact SHA-256,
|
||||
runtime payload SHA-256, and future qualification-policy identities. An external
|
||||
benchmark can compare `/stats.rollout.registry_digest` and `snapshot_digest`
|
||||
between A1 passthrough and B Headroom arms without importing Headroom internals.
|
||||
A mismatch makes the future experiment invalid; benchmark logic itself is out of
|
||||
scope for runtime rollout.
|
||||
|
||||
## Evidence-backed graduation and rollback
|
||||
|
||||
Features progress from canary through beta toward stable only with linked
|
||||
deterministic, integration, and benchmark evidence. **Bake time is evidence, not
|
||||
qualification by itself.** Stable eligibility is followed by release
|
||||
qualification before behavior becomes a stable default.
|
||||
|
||||
Every rollout-managed behavior must have a fast disable path. Operational
|
||||
rollback uses `HEADROOM_DISABLE_FEATURES`; source rollback reverts the defining
|
||||
change. The unsafe override is for diagnostics, not promotion or passing release
|
||||
evidence.
|
||||
|
||||
Contributors should add named registry entries and tests for default behavior,
|
||||
explicit request, channel blocking, disable precedence, unsafe behavior,
|
||||
decision reasons, and provenance rather than reading rollout variables inside
|
||||
implementation components. Python and Rust registries contain features relevant
|
||||
to their own runtimes, but share channel ordering, precedence, decision reasons,
|
||||
fail-closed invalid-input semantics, and deterministic identity semantics.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
223
docs/metrics-technical-guide.md
Normal file
223
docs/metrics-technical-guide.md
Normal 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 (0–100). |
|
||||
| `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.
|
||||
|
||||
---
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ------------------------------------------- #
|
||||
|
||||
|
||||
|
|
|
|||
56
headroom/cache/prefix_tracker.py
vendored
56
headroom/cache/prefix_tracker.py
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from . import ( # noqa: F401
|
|||
perf,
|
||||
proxy,
|
||||
recover,
|
||||
rollout,
|
||||
tools,
|
||||
update,
|
||||
wrap,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
66
headroom/cli/rollout.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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", {}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue