diff --git a/.cargo/audit.toml b/.cargo/audit.toml index e8a321e36a..39a32128eb 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -3,10 +3,10 @@ # 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). +# The `cargo-audit` job in .github/workflows/security.yml is a BLOCKING gate. +# It runs on every PR and push to main whatever paths changed, and weekly on +# that workflow's schedule, 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 diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bb0ed15dc7..f289519d99 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,30 +1,49 @@ -{ - "name": "headroom-marketplace", - "owner": { - "name": "Headroom Contributors" - }, - "metadata": { - "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.39.1" - }, - "plugins": [ - { - "name": "headroom", - "source": "./plugins/headroom-agent-hooks", - "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.39.1", - "author": { - "name": "Headroom Contributors", - "url": "https://github.com/headroomlabs-ai/headroom" - }, - "homepage": "https://github.com/headroomlabs-ai/headroom", - "repository": "https://github.com/headroomlabs-ai/headroom", - "keywords": [ - "headroom", - "hooks", - "claude-code", - "copilot-cli" - ] - } - ] -} +{ + "name": "headroom-marketplace", + "owner": { + "name": "Headroom Contributors" + }, + "metadata": { + "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", + "version": "0.40.0" + }, + "plugins": [ + { + "name": "headroom", + "source": "./plugins/headroom-agent-hooks", + "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", + "version": "0.40.0", + "author": { + "name": "Headroom Contributors", + "url": "https://github.com/headroomlabs-ai/headroom" + }, + "homepage": "https://github.com/headroomlabs-ai/headroom", + "repository": "https://github.com/headroomlabs-ai/headroom", + "keywords": [ + "headroom", + "hooks", + "claude-code", + "copilot-cli" + ] + }, + { + "name": "headroom-snip", + "source": "./plugins/headroom-snip", + "description": "Claude Code mod: watch Headroom snip tokens off every request, with an animated band, a /headroom log and a running total.", + "version": "0.40.0", + "author": { + "name": "Headroom Contributors", + "url": "https://github.com/headroomlabs-ai/headroom" + }, + "homepage": "https://github.com/headroomlabs-ai/headroom", + "repository": "https://github.com/headroomlabs-ai/headroom", + "keywords": [ + "headroom", + "claude-code", + "tokens", + "compression", + "mod" + ] + } + ] +} diff --git a/.dockerignore b/.dockerignore index e98d158c4f..38ce4fa603 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,13 @@ __pycache__ dist/ build/ +# Rust artifacts. The Dockerfile runs its own `cargo build` inside the +# builder stage, so the host's target/ is never needed — and after a +# local `cargo build` it is 100GB+, which the daemon dutifully copies +# on every `docker build`. CI never noticed because a fresh checkout +# has no target/; it only ever bites local builds. +target/ + # Dev/test tooling .pytest_cache .coverage diff --git a/.github/assets/enterprise-perk.svg b/.github/assets/enterprise-perk.svg new file mode 100644 index 0000000000..a03ae4d9e6 --- /dev/null +++ b/.github/assets/enterprise-perk.svg @@ -0,0 +1,96 @@ + + Headroom Enterprise — 3 months free with a 12-month plan + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HEADROOM ENTERPRISE · FOR TEAMS + + + 3 months free + with a 12-month plan · one perk per company + + + + + Tool search + + + Model routing + + + Attack removal + + + Org-wide savings + + + + + + + SSO · VPC or managed + + + + + Claim the perk → + headroom-perks.vercel.app + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index afe046bfcd..d2b1ccbd83 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -44,11 +44,9 @@ updates: # upgrading silently disables compression (GH #1216). - dependency-name: "tree-sitter-language-pack" versions: [">=1.0.0,<2.0.0"] - # These releases do not fix the optional-extra advisories documented in + # These releases do not fix the optional-extra advisory documented in # SECURITY.md. Keep future versions eligible for review; do not suppress # alerts or ignore these packages indefinitely. - - dependency-name: "chromadb" - versions: ["<=1.5.9"] - dependency-name: "accelerate" versions: ["<=1.15.0"] groups: diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index bb0ed15dc7..26aced8443 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.39.1" + "version": "0.40.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.39.1", + "version": "0.40.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/headroomlabs-ai/headroom" diff --git a/.github/pr-images/pr-399/dashboard-cache-ttl-main.png b/.github/pr-images/pr-399/dashboard-cache-ttl-main.png new file mode 100644 index 0000000000..68096c1b47 Binary files /dev/null and b/.github/pr-images/pr-399/dashboard-cache-ttl-main.png differ diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml index bde07e1c5c..cc2a181d04 100644 --- a/.github/workflows/changelog-guard.yml +++ b/.github/workflows/changelog-guard.yml @@ -21,8 +21,8 @@ permissions: jobs: no-manual-changelog: - # release-please's release PR is the sole author of CHANGELOG.md. - if: ${{ !startsWith(github.head_ref, 'release-please--') }} + # release-please's release PR is the sole author of CHANGELOG.md, and a branch that merges upstream carries upstream's own changelog. + if: ${{ !startsWith(github.head_ref, 'release-please--') && !(startsWith(github.head_ref, 'reconcile/upstream-') && github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest steps: - name: Reject manual CHANGELOG.md edits diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 637fd094f1..c4da15e63c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -422,6 +422,9 @@ jobs: --tb=short -q - name: Upload coverage shard ${{ matrix.shard }} to Codecov + # A shard with a failing test still writes its report; skipping the + # upload drops every line only that shard covers from codecov/patch. + if: ${{ !cancelled() }} uses: codecov/codecov-action@v5 with: files: coverage-${{ matrix.shard }}.xml @@ -564,7 +567,8 @@ jobs: retention-days: 7 commitlint: - if: github.event_name == 'pull_request' + # A branch that merges upstream carries upstream's own commit history, which this repository cannot rewrite and which its own rules do not govern. + if: github.event_name == 'pull_request' && !(startsWith(github.head_ref, 'reconcile/upstream-') && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -706,6 +710,43 @@ jobs: - name: Run native installer wrapper tests run: pytest tests/test_install/test_native_installers.py -q + # Runtime coverage for pure-Python proxy logic on Windows, kept separate + # from windows-native-wrapper (above), which is deliberately + # dependency-minimal and gated on native-installer changes only. Installs + # the latest published wheel from PyPI (real precompiled _core.pyd, no + # cargo/maturin build needed) so this branch's local ./headroom source + # tree can shadow it -- same shadowing the Linux `test` job relies on, + # just without needing our own freshly-built wheel, since none of the + # covered test files import headroom._core. + # + # Covers the full disk-verify session-token path, not just the + # interceptor: OpenCode config/launch-env building, the wrap opencode + # CLI command, and workspace-root registration through the real HTTP + # middleware -- so this gate actually exercises the OpenCode path, not + # only its final consumer. + windows-tool-result-interceptors: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PY_VERSION }} + - name: Install headroom-ai[dev] (published wheel; local source shadows it) + run: | + python -m pip install --upgrade pip + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install "headroom-ai[dev]" + - name: Run disk-verify session-token path tests + shell: bash + run: | + pytest tests/test_tool_result_interceptors.py \ + tests/test_providers_opencode_config.py \ + tests/test_cli/test_wrap_opencode.py \ + tests/test_proxy_workspace_registration_middleware.py -q + macos-native-wrapper: needs: changes if: needs.changes.outputs.native == 'true' diff --git a/.github/workflows/devcontainers.yml b/.github/workflows/devcontainers.yml index 214ae75f98..fff595f833 100644 --- a/.github/workflows/devcontainers.yml +++ b/.github/workflows/devcontainers.yml @@ -17,6 +17,9 @@ on: - "uv.lock" workflow_dispatch: +permissions: + contents: read + jobs: validate: runs-on: ubuntu-latest diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1c11d452cb..41ab3c9955 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -124,6 +124,7 @@ jobs: - name: Build and push by digest (single platform) id: bake uses: docker/bake-action@v7 + continue-on-error: true with: files: | ./docker-bake.hcl @@ -147,7 +148,33 @@ jobs: *.platform=${{ matrix.arch.platform }} *.output=type=image,name=${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true *.cache-from=type=gha,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }} - *.cache-to=type=gha,mode=max,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }} + *.cache-to=type=gha,mode=max,ignore-error=true,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }} + + # BuildKit can load a valid GHA cache index whose backing blob has + # since been evicted. The resulting lazy-copy 404 is fatal to cache + # import. Use a fresh daemon for the retry so the failed builder's + # imported records cannot be selected from its local state. + - name: Set up clean Docker Buildx fallback + id: fallback-buildx + if: steps.bake.outcome == 'failure' + uses: docker/setup-buildx-action@v4 + + # A real build failure remains fatal on this uncached attempt. + - name: Retry build without remote cache + id: bake-fallback + if: steps.bake.outcome == 'failure' + uses: docker/bake-action@v7 + with: + builder: ${{ steps.fallback-buildx.outputs.name }} + no-cache: true + files: | + ./docker-bake.hcl + cwd://${{ steps.meta.outputs.bake-file-labels }} + targets: ${{ matrix.variant.bake_target }} + push: true + set: | + *.platform=${{ matrix.arch.platform }} + *.output=type=image,name=${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true - name: Export digest id: digest @@ -159,7 +186,7 @@ jobs: # runner exports every env entry at bash spawn, so a large # metadata blob fails the step with E2BIG before it runs. cat > "${RUNNER_TEMP}/bake_meta.json" <<'__HEADROOM_BAKE_META_EOF__' - ${{ steps.bake.outputs.metadata }} + ${{ steps.bake.outcome == 'success' && steps.bake.outputs.metadata || steps.bake-fallback.outputs.metadata }} __HEADROOM_BAKE_META_EOF__ digest="$(jq -r 'to_entries[0].value."containerimage.digest" // empty' \ "${RUNNER_TEMP}/bake_meta.json")" @@ -220,7 +247,7 @@ jobs: # tags, and that manifest is what users pull by `:tag`. docker-manifest: needs: docker-build - if: ${{ always() }} + if: ${{ success() }} runs-on: ubuntu-24.04 timeout-minutes: 20 strategy: diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index c6c879f30f..d32489f872 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -10,6 +10,9 @@ on: - 'headroom/evals/**' - 'headroom/compress.py' +permissions: + contents: read + jobs: # Fast smoke test on PRs touching compression code (~$0.05, ~2 min) smoke-test: diff --git a/.github/workflows/init-e2e.yml b/.github/workflows/init-e2e.yml index 8ec26ee1b6..e6b4531a33 100644 --- a/.github/workflows/init-e2e.yml +++ b/.github/workflows/init-e2e.yml @@ -31,6 +31,9 @@ concurrency: group: init-e2e-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +permissions: + contents: read + jobs: docker-init-e2e: runs-on: ubuntu-latest diff --git a/.github/workflows/init-native-e2e.yml b/.github/workflows/init-native-e2e.yml index 952db263b2..3e125771b7 100644 --- a/.github/workflows/init-native-e2e.yml +++ b/.github/workflows/init-native-e2e.yml @@ -32,6 +32,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + jobs: init-native: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/opencode-plugin.yml b/.github/workflows/opencode-plugin.yml index 5d5dd209a4..aa783eaf59 100644 --- a/.github/workflows/opencode-plugin.yml +++ b/.github/workflows/opencode-plugin.yml @@ -44,15 +44,15 @@ jobs: - run: npm run build - run: npm test # The wheel ships a committed self-contained bundle at - # headroom/providers/opencode/_dist/entry.opencode.js so pip installs - # get the transport plugin too. Rebuild it and fail if the committed - # artifact has drifted from the source. + # headroom/providers/opencode/_dist/index.js so pip installs get the + # transport plugin too. Rebuild it and fail if the committed artifact + # has drifted from the source. - run: npm run build:standalone - name: verify committed wheel bundle matches source run: | - cmp dist-standalone/entry.opencode.js \ - ../../headroom/providers/opencode/_dist/entry.opencode.js \ - || { echo "::error::headroom/providers/opencode/_dist/entry.opencode.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; } + cmp dist-standalone/index.js \ + ../../headroom/providers/opencode/_dist/index.js \ + || { echo "::error::headroom/providers/opencode/_dist/index.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; } cmp dist-standalone/hook-shim/handler.js \ ../../headroom/providers/opencode/hook-shim/handler.js \ || { echo "::error::headroom/providers/opencode/hook-shim/handler.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; } diff --git a/.github/workflows/pr-health.yml b/.github/workflows/pr-health.yml index 92b6182631..bbaea26cdf 100644 --- a/.github/workflows/pr-health.yml +++ b/.github/workflows/pr-health.yml @@ -27,7 +27,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.sha }} - name: Fetch current PR body env: @@ -153,7 +153,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.sha }} - name: Ensure maintenance labels exist run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6ad34145d..a7e90bf776 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,7 +245,7 @@ jobs: - os: macos-15-intel target: x86_64-apple-darwin manylinux: "" - - os: macos-14 + - os: macos-26 target: aarch64-apple-darwin manylinux: "" # Windows x86_64. `windows-latest` ships the MSVC toolchain and @@ -498,7 +498,7 @@ jobs: - { runner: ubuntu-24.04-arm, image: "quay.io/pypa/manylinux_2_28_aarch64", python: "3.11", wheel_target: aarch64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-arm-aarch64-unknown-linux-gnu, glibc_label: "2.28-floor" } - { runner: ubuntu-24.04-arm, image: "ubuntu:22.04", python: "3.12", wheel_target: aarch64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-arm-aarch64-unknown-linux-gnu, glibc_label: "2.35" } # macOS arm64 — runs natively on the host runner; no container. - - { runner: macos-14, image: "", python: "3.13", wheel_target: aarch64-apple-darwin, wheel_artifact: wheels-macos-14-aarch64-apple-darwin, glibc_label: "" } + - { runner: macos-26, image: "", python: "3.13", wheel_target: aarch64-apple-darwin, wheel_artifact: wheels-macos-26-aarch64-apple-darwin, glibc_label: "" } # macOS x86_64 (Intel) — `ort-load-dynamic`; see build-wheels # matrix comment for rationale. - { runner: macos-15-intel, image: "", python: "3.12", wheel_target: x86_64-apple-darwin, wheel_artifact: wheels-macos-15-intel-x86_64-apple-darwin, glibc_label: "" } @@ -713,7 +713,7 @@ jobs: echo "Installing: $whl" # Use the system python at the requested minor version. The - # macos-14 runner image preinstalls multiple Python versions. + # macOS runner image preinstalls multiple Python versions. "python$PYTHON_VERSION" -m venv /tmp/venv /tmp/venv/bin/pip install --quiet --upgrade pip /tmp/venv/bin/pip install --quiet "$whl" @@ -789,7 +789,10 @@ jobs: # recommended pattern for release workflows that may run # multiple times against the same version. skip-existing: true - attestations: false + # PEP 740 attestations from this job's trusted-publishing identity + # (needs only `id-token: write`, granted above). Off since #2405 with + # no recorded root cause; if a release fails on them, revert this line. + attestations: true publish-npm: needs: [detect-version, build] @@ -1062,6 +1065,26 @@ jobs: gh release create "$TAG" --title "$TITLE" --notes-file .changelog.md fi + # Appends once: a re-run finds this step's own marker and leaves the notes + # alone. Keyed on the marker, not the link, so a changelog entry that + # happens to mention the perks page does not suppress the footer. + # Release notes stay the changelog; this is one line under it. + - name: Add Headroom for Teams footer to release notes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ needs.detect-version.outputs.version }}" + MARKER='' + gh release view "$TAG" --json body --jq .body > release-notes.md + if ! grep -qF "$MARKER" release-notes.md; then + { + printf '\n%s\n\n---\n\n' "$MARKER" + printf '**Headroom for Teams:** 3 months of Headroom Enterprise free with a 12-month plan. ' + printf '[Claim the perk →](https://headroom-perks.vercel.app/?source=github&utm_source=github&utm_medium=release-notes&utm_content=%s)\n' "$TAG" + } >> release-notes.md + gh release edit "$TAG" --notes-file release-notes.md + fi + - name: Publish ${{ env.PYPI_PACKAGE }} Python distributions to GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c1c3799759..bd8c598c60 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -179,7 +179,7 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu maturin-target: x86_64 - - os: macos-14 + - os: macos-26 target: aarch64-apple-darwin maturin-target: aarch64-apple-darwin - os: macos-15-intel @@ -208,32 +208,10 @@ jobs: name: wheels-${{ matrix.target }} path: dist/*.whl - audit: - name: audit - needs: rust-changes - if: needs.rust-changes.outputs.rust == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: 1.95.0 - - uses: Swatinem/rust-cache@v2 - - name: Install cargo-audit + cargo-deny - uses: taiki-e/install-action@v2 - with: - tool: cargo-audit,cargo-deny - # 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 - run: cargo deny check licenses + # The Rust dependency advisory scan (`cargo audit`) moved to security.yml, + # where it runs on every PR and push regardless of which paths changed. Here + # it sat behind the `rust-changes` filter and this workflow's schedule, and + # that combination let three weeks pass with no scan at all. # Blocking on every PR that touches Rust. Safe to harden now because the # harness fails only on a Diff — `parity-run` sets `any_diffs` inside the diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml new file mode 100644 index 0000000000..ad7f9efe30 --- /dev/null +++ b/.github/workflows/sdk-typescript.yml @@ -0,0 +1,43 @@ +name: SDK (TypeScript) + +# The TypeScript SDK (sdk/typescript, published to npm as `headroom-ai`) has a +# vitest suite, a typecheck and a build, yet nothing in CI ever ran them. Two +# individually green PRs could leave `npm test` red on main (#3424 + #3370, +# repaired in #3796), and a dependabot bump of `ai` could break three adapter +# tests without anyone noticing (#3910). This gate runs the SDK's own +# test + typecheck + build whenever it (or this workflow) changes, mirroring +# the OpenCode plugin gate in opencode-plugin.yml. +on: + pull_request: + branches: [main] + paths: + - "sdk/typescript/**" + - ".github/workflows/sdk-typescript.yml" + push: + branches: [main] + paths: + - "sdk/typescript/**" + - ".github/workflows/sdk-typescript.yml" + +permissions: + contents: read + +jobs: + test: + name: test + typecheck + build + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: sdk/typescript + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ">=24.16.0 <25" + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + - run: npm ci + - run: npm test + - run: npm run typecheck + - run: npm run build diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3db21f2636..f57c8670aa 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -55,6 +55,35 @@ jobs: with: inputs: requirements-prod.txt + # ---- SCA: Rust dependency advisories ----------------------------------- + # `cargo audit` reads Cargo.lock only (no build, about a minute), so it runs + # unconditionally: every PR, every push to main, and the weekly schedule + # above. It used to live in rust.yml behind the `rust-changes` path filter; + # pushes that touched no Rust path reported it `skipped`, and rust.yml's own + # schedule stopped firing after 2026-09-08, so no advisory scan ran for three + # weeks. Blocking: an accepted advisory goes in .cargo/audit.toml with a + # written reason. + cargo-audit: + name: Rust advisories (cargo audit) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Install cargo-audit + cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit,cargo-deny + + - name: cargo audit (blocking; exceptions in .cargo/audit.toml) + run: cargo audit + + # Licence allow-list from deny.toml. Advisory decisions stay with + # cargo-audit above, so there is one place that decides what is accepted. + - name: cargo deny check licenses + continue-on-error: true + run: cargo deny check licenses + # Audit every maintained npm lock, including development dependencies. # Lockfile audits do not install packages or execute their lifecycle scripts. npm-audit: @@ -91,7 +120,7 @@ jobs: strategy: fail-fast: false matrix: - language: [python, javascript-typescript] + language: [python, javascript-typescript, actions] steps: - uses: actions/checkout@v7 @@ -100,6 +129,12 @@ jobs: with: languages: ${{ matrix.language }} queries: security-extended + # Test code never ships. Its alerts were all test idioms (asserting a + # URL appears in output, a fake server binding 0.0.0.0), so scanning + # it only buried the real findings. + config: | + paths-ignore: + - tests - name: Perform CodeQL analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/wrap-e2e.yml b/.github/workflows/wrap-e2e.yml index dae4a147a8..0044c5df77 100644 --- a/.github/workflows/wrap-e2e.yml +++ b/.github/workflows/wrap-e2e.yml @@ -30,6 +30,9 @@ concurrency: group: wrap-e2e-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +permissions: + contents: read + jobs: docker-wrap-e2e: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index fd0e2ce498..3445a7647f 100644 --- a/.gitignore +++ b/.gitignore @@ -214,6 +214,7 @@ examples/vertex_gemini_benchmark/results.json # Headroom specific .headroom/ .claude/.headroom_wrap_*.json +.claude/.headroom_wrap_settings.lock headroom.db headroom_*.db *.jsonl diff --git a/.release-please-manifest.json b/.release-please-manifest.json index faf40d7e2b..6507815346 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.39.1" + ".": "0.40.0" } diff --git a/.releasemetadata b/.releasemetadata index 85e959fd7f..80652f3c16 100644 --- a/.releasemetadata +++ b/.releasemetadata @@ -1,10 +1,10 @@ { - "version": "0.39.1", + "version": "0.40.0", "packages": { - "pypi": "0.39.1", - "npm-sdk": "0.39.1", - "npm-openclaw": "0.39.1", - "npm-opencode": "0.39.1", - "agent-hooks-plugin": "0.39.1" + "pypi": "0.40.0", + "npm-sdk": "0.40.0", + "npm-openclaw": "0.40.0", + "npm-opencode": "0.40.0", + "agent-hooks-plugin": "0.40.0" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index d4541b6974..d3eac91b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,201 @@ 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.40.0](https://github.com/headroomlabs-ai/headroom/compare/v0.39.1...v0.40.0) (2026-10-06) + + +### ⚠ BREAKING CHANGES + +* **proxy:** read HEADROOM_LICENSE and make usage reporting opt-in ([#3857](https://github.com/headroomlabs-ai/headroom/issues/3857)) +* drop the crewai extra to remove chromadb from the lockfile ([#3870](https://github.com/headroomlabs-ai/headroom/issues/3870)) + +### Features + +* **compress:** add per-message compression diagnostics ([#3058](https://github.com/headroomlabs-ai/headroom/issues/3058)) ([fef99cc](https://github.com/headroomlabs-ai/headroom/commit/fef99cc5b444bb16ac33fe8ec70108f7c0271de1)) +* **compress:** live-agent densify mode, lossless and cache-safe ([#1402](https://github.com/headroomlabs-ai/headroom/issues/1402)) ([84f0e84](https://github.com/headroomlabs-ai/headroom/commit/84f0e84dccd3736b4a0c6c817bc83da307a5fbb8)) +* **dashboard:** add CO₂ Saved card to dashboard and /stats API ([#1369](https://github.com/headroomlabs-ai/headroom/issues/1369)) ([2bc9419](https://github.com/headroomlabs-ai/headroom/commit/2bc94194671548c5def53809f736340285e20e76)) +* **dashboard:** give savings metrics one canonical home ([#3320](https://github.com/headroomlabs-ai/headroom/issues/3320)) ([6af7efe](https://github.com/headroomlabs-ai/headroom/commit/6af7efe0d1fa9786d1fd5e5dc9884b7750c0f569)) +* **learn:** add agy (Antigravity CLI) as an analysis backend ([#3939](https://github.com/headroomlabs-ai/headroom/issues/3939)) ([4ba231a](https://github.com/headroomlabs-ai/headroom/commit/4ba231ab3d51c409804af24ea03692aed33deb2e)) +* **live-zone:** wire the SourceCode and PlainText dispatch arms ([#3227](https://github.com/headroomlabs-ai/headroom/issues/3227)) ([49f69be](https://github.com/headroomlabs-ai/headroom/commit/49f69be2836e90cbd210e39d82b36d514404cc76)) +* **memory:** persist TrafficLearner pending evidence across restarts + expose learner stats ([#3104](https://github.com/headroomlabs-ai/headroom/issues/3104)) ([4257ed4](https://github.com/headroomlabs-ai/headroom/commit/4257ed4df377a93cd2e0d526ef61d2a32cbc54cf)) +* **proxy:** add x-headroom-keep-last-turns header for per-request context trimming (issue [#2858](https://github.com/headroomlabs-ai/headroom/issues/2858)) ([#3059](https://github.com/headroomlabs-ai/headroom/issues/3059)) ([246162d](https://github.com/headroomlabs-ai/headroom/commit/246162d70bbc68cc1b7d44a015ddaf320cd23a3d)) +* **proxy:** route Gemini plugin traffic through native transforms ([#2697](https://github.com/headroomlabs-ai/headroom/issues/2697)) ([0ad996a](https://github.com/headroomlabs-ai/headroom/commit/0ad996a82eef65183271243fa71bb0f5653be369)) +* **proxy:** serve on a Unix domain socket (--uds) ([#3151](https://github.com/headroomlabs-ai/headroom/issues/3151)) ([e7b3baf](https://github.com/headroomlabs-ai/headroom/commit/e7b3baf236b832e1d4d086c4425b8d32560d0d7c)) +* **wrap:** add headroom wrap bob for IBM Bob CLI ([#3801](https://github.com/headroomlabs-ai/headroom/issues/3801)) ([0b7dab4](https://github.com/headroomlabs-ai/headroom/commit/0b7dab47c62a8372c7bf7031d744658d71745679)) +* **wrap:** extend reduce-at-source quiet defaults to telemetry/nag banners ([#2550](https://github.com/headroomlabs-ai/headroom/issues/2550)) ([3a3a465](https://github.com/headroomlabs-ai/headroom/commit/3a3a465bb129ad74f6e04f7c50e0fe7489383ba2)) + + +### Bug Fixes + +* **anthropic:** preserve failed CCR continuations ([#3843](https://github.com/headroomlabs-ai/headroom/issues/3843)) ([94bb055](https://github.com/headroomlabs-ai/headroom/commit/94bb055810dc1258bd248cd0a9d0e7c51df64844)) +* **backends/anyllm:** map Anthropic tool_choice none to OpenAI none ([#3963](https://github.com/headroomlabs-ai/headroom/issues/3963)) ([d722a64](https://github.com/headroomlabs-ai/headroom/commit/d722a6414c3896ef092a0d8764c64d9681e07410)) +* **backends/litellm:** align streaming usage tokens with the non-streaming path ([#2688](https://github.com/headroomlabs-ai/headroom/issues/2688)) ([90a68e0](https://github.com/headroomlabs-ai/headroom/commit/90a68e02910126373f3d3769158925e3a55ce411)) +* **backends/litellm:** keep the upstream 4xx status on send_message errors ([#3944](https://github.com/headroomlabs-ai/headroom/issues/3944)) ([e0e41cd](https://github.com/headroomlabs-ai/headroom/commit/e0e41cd2f57a80612ddbf1568e4ad245046a0131)) +* **backends/litellm:** map Anthropic tool_choice none to OpenAI none ([#2689](https://github.com/headroomlabs-ai/headroom/issues/2689)) ([57a5708](https://github.com/headroomlabs-ai/headroom/commit/57a57088de8920091ecaaac0e3cadf3932698154)) +* **backends/litellm:** match the caller-key Bearer scheme case-insensitively (RFC 7235) ([#3965](https://github.com/headroomlabs-ai/headroom/issues/3965)) ([848d7a0](https://github.com/headroomlabs-ai/headroom/commit/848d7a04b489600d3c0de0ba4f7c47cdb8ed5cc2)) +* **backends/litellm:** report requested model in OpenAI streaming chunks ([#2690](https://github.com/headroomlabs-ai/headroom/issues/2690)) ([8538a83](https://github.com/headroomlabs-ai/headroom/commit/8538a831531bb277a6083ed2a010312a849c3050)) +* **cache:** keep prefix lineage across a replaced system tail ([#3934](https://github.com/headroomlabs-ai/headroom/issues/3934)) ([16eaec1](https://github.com/headroomlabs-ai/headroom/commit/16eaec1e95cd39135ef06f6664b309c71b03e3ad)) +* **ccr:** answer headroom_retrieve on the direct chat path instead of forwarding it ([#3816](https://github.com/headroomlabs-ai/headroom/issues/3816)) ([126e144](https://github.com/headroomlabs-ai/headroom/commit/126e144876d2bd01246b5066dd5d3379b7a0e2da)) +* **ccr:** inject headroom_retrieve before the prefix is warm, not after ([#3810](https://github.com/headroomlabs-ai/headroom/issues/3810)) ([bb8c285](https://github.com/headroomlabs-ai/headroom/commit/bb8c285714b19dadf4f96305ba5a74877a0dd103)) +* **ccr:** only proactively expand compressions present in the requesting conversation ([#3924](https://github.com/headroomlabs-ai/headroom/issues/3924)) ([9b9a883](https://github.com/headroomlabs-ai/headroom/commit/9b9a883e96ec28e361a3d95e34ec29ecd2ef8abb)) +* **ccr:** unwrap Hermes batched tool_call so headroom_retrieve stays exempt ([#3839](https://github.com/headroomlabs-ai/headroom/issues/3839)) ([2f07668](https://github.com/headroomlabs-ai/headroom/commit/2f076687b069f09c69a1ce3e6f3eaaa521b838f7)) +* **ci:** clear dependency audit and gate Docker publishing ([#3984](https://github.com/headroomlabs-ai/headroom/issues/3984)) ([67ce7d0](https://github.com/headroomlabs-ai/headroom/commit/67ce7d0f3119883a873a53c1fb1e569c80d36d71)) +* **ci:** keep hard watchdog out of pytest shards ([#3845](https://github.com/headroomlabs-ai/headroom/issues/3845)) ([2527431](https://github.com/headroomlabs-ai/headroom/commit/25274310ca396aaf5761d78d07106de3dfde2951)) +* **ci:** tolerate missing Docker cache blobs ([#3945](https://github.com/headroomlabs-ai/headroom/issues/3945)) ([540f4da](https://github.com/headroomlabs-ai/headroom/commit/540f4da19995ce9ec60240f991a8091466ee7e51)) +* clarify CCR marker content preservation ([#3175](https://github.com/headroomlabs-ai/headroom/issues/3175)) ([005a4e1](https://github.com/headroomlabs-ai/headroom/commit/005a4e146f94e2388e8e0e7eaabe5a831676b26b)) +* **cli:** honor CODEX_HOME and align wrap/init/doctor with the live proxy port ([#3855](https://github.com/headroomlabs-ai/headroom/issues/3855)) ([5bf6612](https://github.com/headroomlabs-ai/headroom/commit/5bf661232667fe2573caafcd9b48f86c6388b5bd)) +* **cli:** strip unmarked headroom_memory TOML table before injecting ([#3490](https://github.com/headroomlabs-ai/headroom/issues/3490)) ([08dda4c](https://github.com/headroomlabs-ai/headroom/commit/08dda4cb0b5fe69bff8aa1deeabfb2111738087c)) +* **codex:** explain why wrapped Codex runs without the shared server ([#3899](https://github.com/headroomlabs-ai/headroom/issues/3899)) ([1982b3a](https://github.com/headroomlabs-ai/headroom/commit/1982b3a773093ff8262887e69ffb12e342ce4ffc)) +* **codex:** preserve remote compaction in init config ([#3410](https://github.com/headroomlabs-ai/headroom/issues/3410)) ([6df4a96](https://github.com/headroomlabs-ai/headroom/commit/6df4a963ae4cfefa2269b751874e142b02feb726)) +* **codex:** resolve per-turn project context ([#2636](https://github.com/headroomlabs-ai/headroom/issues/2636)) ([0396ea2](https://github.com/headroomlabs-ai/headroom/commit/0396ea2fc22d72d95d1f3bfa63bfbdd0abd74a80)) +* **codex:** strip stale compression framing on the Responses subpath passthrough ([#3792](https://github.com/headroomlabs-ai/headroom/issues/3792)) ([0eba65c](https://github.com/headroomlabs-ai/headroom/commit/0eba65ca4a8f04b55d568fc35829eca053a7cfac)) +* **copilot:** defer keychain auth lookup ([#2739](https://github.com/headroomlabs-ai/headroom/issues/2739)) ([81a8a28](https://github.com/headroomlabs-ai/headroom/commit/81a8a28da38f1837c6b8dd6217a35a5ebb98bc87)) +* **copilot:** read timezone-naive token expiry as UTC, not host local time ([#3216](https://github.com/headroomlabs-ai/headroom/issues/3216)) ([2b2dc1b](https://github.com/headroomlabs-ai/headroom/commit/2b2dc1b2c93c596ad5129dff914978c5679ef126)) +* **copilot:** warn when a VS Code profile cannot see the proxy settings ([#3919](https://github.com/headroomlabs-ai/headroom/issues/3919)) ([7b68fc2](https://github.com/headroomlabs-ai/headroom/commit/7b68fc263a925f97116e1e648cd3fa22a0f04f1b)) +* **deps:** patch brace-expansion in wrap E2E lockfile ([#3873](https://github.com/headroomlabs-ai/headroom/issues/3873)) ([5e0435d](https://github.com/headroomlabs-ai/headroom/commit/5e0435d2156229c04951da9fafeafdf74d2abf84)) +* **deps:** patch urllib3 and Next.js advisories ([#3896](https://github.com/headroomlabs-ai/headroom/issues/3896)) ([573e385](https://github.com/headroomlabs-ai/headroom/commit/573e385f120cfd4f5e4d54c791025f2253abf08a)) +* **doctor:** recognize Azure Foundry routing for Claude Code ([#1339](https://github.com/headroomlabs-ai/headroom/issues/1339)) ([d5318ac](https://github.com/headroomlabs-ai/headroom/commit/d5318ac235595b9740fccb0910ec7139c42d51a0)) +* **grok:** route CLI traffic to api.x.ai on shared proxies ([#2693](https://github.com/headroomlabs-ai/headroom/issues/2693)) ([ef1c528](https://github.com/headroomlabs-ai/headroom/commit/ef1c528e92a2b037a170f94ec8adf917322ba630)) +* honor configured port in container startup and healthcheck ([#2436](https://github.com/headroomlabs-ai/headroom/issues/2436)) ([1b7977c](https://github.com/headroomlabs-ai/headroom/commit/1b7977cecd95e53cccf45f04248097a8e4137b3f)) +* **install/apply:** add --no-rate-limit flag, persist in proxy_args ([#1350](https://github.com/headroomlabs-ai/headroom/issues/1350)) ([#1365](https://github.com/headroomlabs-ai/headroom/issues/1365)) ([31d0344](https://github.com/headroomlabs-ai/headroom/commit/31d03449910fbacbefdde8c04ea2c10299f9bba6)) +* **install:** parse Windows Task Scheduler XML output ([#3830](https://github.com/headroomlabs-ai/headroom/issues/3830)) ([c32a4f4](https://github.com/headroomlabs-ai/headroom/commit/c32a4f4131934dee48d793156fb8a536a4b2617e)) +* **install:** replace the launchd wrapper with the proxy listener ([#3224](https://github.com/headroomlabs-ai/headroom/issues/3224)) ([44db755](https://github.com/headroomlabs-ai/headroom/commit/44db755ee6d85d0eb74f1fc79ef737741f87debc)) +* **install:** use safe model backends for persistent services ([#3646](https://github.com/headroomlabs-ai/headroom/issues/3646)) ([7287589](https://github.com/headroomlabs-ai/headroom/commit/7287589d23b76911b036d97f58c3cc043a9693c8)) +* **integrations:** record metric timestamps in UTC, not naive local time ([#3117](https://github.com/headroomlabs-ai/headroom/issues/3117)) ([76ef2c3](https://github.com/headroomlabs-ai/headroom/commit/76ef2c3bc9cc302f4b638fab83467ab53cb192c1)) +* **kompress:** degrade when a native dep is installed but unloadable ([#3133](https://github.com/headroomlabs-ai/headroom/issues/3133)) ([8f3d677](https://github.com/headroomlabs-ai/headroom/commit/8f3d677354f50df9181cbb3fa002003be4e3c237)) +* **kompress:** pin ModernBERT tokenizer and encoder revisions ([#3808](https://github.com/headroomlabs-ai/headroom/issues/3808)) ([2de5828](https://github.com/headroomlabs-ai/headroom/commit/2de582875b7dd1ef8754df8a2905ab4d72dab581)) +* **learn:** drop the echoed prompt from a failed cli's error ([#3928](https://github.com/headroomlabs-ai/headroom/issues/3928)) ([f00d425](https://github.com/headroomlabs-ai/headroom/commit/f00d42561402752e56666ff35962ad8da6f7966c)) +* **learn:** keep CLAUDE.local.md out of git via .git/info/exclude ([#3108](https://github.com/headroomlabs-ai/headroom/issues/3108)) ([c072251](https://github.com/headroomlabs-ai/headroom/commit/c072251c10a1334265974ed33f655487518d41d0)) +* **learn:** keep transcript-derived content inside the managed block ([#3850](https://github.com/headroomlabs-ai/headroom/issues/3850)) ([c46e74d](https://github.com/headroomlabs-ai/headroom/commit/c46e74d06b5ffa9643f420f28d2da6686fb7e487)) +* **learn:** merge git worktree sessions into the repo's project ([#3854](https://github.com/headroomlabs-ai/headroom/issues/3854)) ([3fdf18e](https://github.com/headroomlabs-ai/headroom/commit/3fdf18ededa162e5f46f5a996a164a6ca9de283f)) +* **learn:** preserve stable traffic pattern items ([#2293](https://github.com/headroomlabs-ai/headroom/issues/2293)) ([1631cde](https://github.com/headroomlabs-ai/headroom/commit/1631cde6865cbc03a60ade5e9baaa28837add4e4)) +* **learn:** recognize escaped persisted pattern IDs ([#3951](https://github.com/headroomlabs-ai/headroom/issues/3951)) ([2e74f06](https://github.com/headroomlabs-ai/headroom/commit/2e74f06ecec73d365e06de6832b13f7e9fe386b7)) +* **learn:** run the claude-cli analysis with hooks disabled ([#3926](https://github.com/headroomlabs-ai/headroom/issues/3926)) ([2a4d34c](https://github.com/headroomlabs-ai/headroom/commit/2a4d34c47676080336577bb4b227864258cb2c78)) +* **learn:** run the claude-cli analysis with no tools ([#3892](https://github.com/headroomlabs-ai/headroom/issues/3892)) ([db90b93](https://github.com/headroomlabs-ai/headroom/commit/db90b93da9f8decb9cac7fa007674de7974a1cdb)) +* **log_compressor:** keep and name pytest short-summary failures ([#3828](https://github.com/headroomlabs-ai/headroom/issues/3828)) ([d90b320](https://github.com/headroomlabs-ai/headroom/commit/d90b32008dc47f25737d9cef8da942fd8dc1af76)) +* make Headroom work behind Zscaler and other TLS-inspecting networks ([#3831](https://github.com/headroomlabs-ai/headroom/issues/3831)) ([66258c4](https://github.com/headroomlabs-ai/headroom/commit/66258c40a4589bb2fef934dec6fb69c527ae8b44)) +* **mcp:** bound version-detection git subprocess ([#3038](https://github.com/headroomlabs-ai/headroom/issues/3038)) ([0712e04](https://github.com/headroomlabs-ai/headroom/commit/0712e048b034e7f15ce10c0fc4e99795cab082ff)) +* **mcp:** escape control characters when rendering TOML server blocks ([#3964](https://github.com/headroomlabs-ai/headroom/issues/3964)) ([a96154f](https://github.com/headroomlabs-ai/headroom/commit/a96154f03b65179f23496a1ff9682cfc476eaff5)) +* **mcp:** keep other apps' tables when replacing the Codex/Grok MCP span ([#3877](https://github.com/headroomlabs-ai/headroom/issues/3877)) ([d0fd56e](https://github.com/headroomlabs-ai/headroom/commit/d0fd56e4910c997a3121aa27ab578b998b260f5a)) +* **mcp:** resolve opencode.jsonc for MCP registration ([#2496](https://github.com/headroomlabs-ai/headroom/issues/2496)) ([f824a27](https://github.com/headroomlabs-ai/headroom/commit/f824a270f132516443deeef2555076bf4cd40a34)) +* **memory:** align project routing with wrap headers ([#3603](https://github.com/headroomlabs-ai/headroom/issues/3603)) ([b1b005a](https://github.com/headroomlabs-ai/headroom/commit/b1b005a9a867d75cbc6b459b1f5276c272e7ac50)) +* **memory:** avoid injecting tools into tool-free requests ([#3677](https://github.com/headroomlabs-ai/headroom/issues/3677)) ([46755b5](https://github.com/headroomlabs-ai/headroom/commit/46755b5f72804c32dc7d6ded5595342a2cac6157)) +* **memory:** close SQLite connections in memory/fts5/graph adapters ([#3153](https://github.com/headroomlabs-ai/headroom/issues/3153)) ([231a627](https://github.com/headroomlabs-ai/headroom/commit/231a6277024938d28e0ebeda29378dea3822b527)) +* **memory:** handle list-shaped system content in inline memory injection ([#3794](https://github.com/headroomlabs-ai/headroom/issues/3794)) ([717527b](https://github.com/headroomlabs-ai/headroom/commit/717527bc8681665326c064ed47e3f5a0284b01a5)) +* **memory:** ignore leading cd prefix when pairing Bash error recoveries ([#3776](https://github.com/headroomlabs-ai/headroom/issues/3776)) ([143a38d](https://github.com/headroomlabs-ai/headroom/commit/143a38d56f5bddb96dd486948c3318bd8dc93372)) +* **memory:** pin LF on memory writers and guard the learn-writer newline contract ([#3706](https://github.com/headroomlabs-ai/headroom/issues/3706)) ([b10dd8d](https://github.com/headroomlabs-ai/headroom/commit/b10dd8db6e71903e550edd1707ce044f3520f0b4)) +* **oauth2:** mint and inject only on requests that go upstream ([#3849](https://github.com/headroomlabs-ai/headroom/issues/3849)) ([f977d52](https://github.com/headroomlabs-ai/headroom/commit/f977d525e4cf4ac1cad64dd59cfc6d3e54cd8e36)) +* **offline:** make HEADROOM_OFFLINE a real air-gap via one chokepoint ([#3729](https://github.com/headroomlabs-ai/headroom/issues/3729)) ([119d1a1](https://github.com/headroomlabs-ai/headroom/commit/119d1a19393697f22140da654f0b6a10c3866d79)) +* **opencode:** hide spawned Windows console windows ([#3743](https://github.com/headroomlabs-ai/headroom/issues/3743)) ([2157400](https://github.com/headroomlabs-ai/headroom/commit/2157400b005b858fb4ab96dadde022bb22d3d244)) +* **opencode:** route only LLM traffic through Headroom ([#3884](https://github.com/headroomlabs-ai/headroom/issues/3884)) ([d75eecd](https://github.com/headroomlabs-ai/headroom/commit/d75eecd814d391f51c6b98b1594c0ea180e9103a)) +* **output-savings:** seed the holdout key on the whole first user message ([#3209](https://github.com/headroomlabs-ai/headroom/issues/3209)) ([117ff72](https://github.com/headroomlabs-ai/headroom/commit/117ff72ec9aa6385c4e6afb23f06a4a6edb59f8e)) +* **parser:** stop counting HTML comments twice in waste signals ([#3942](https://github.com/headroomlabs-ai/headroom/issues/3942)) ([d0e9c4c](https://github.com/headroomlabs-ai/headroom/commit/d0e9c4ca4810f36c8bd71cfc567be9e72c8c06d1)) +* **parser:** whitespace waste signal always reported zero ([#1102](https://github.com/headroomlabs-ai/headroom/issues/1102)) ([f19bc9a](https://github.com/headroomlabs-ai/headroom/commit/f19bc9a9c344e846ef61de09652c55f8db835aa1)) +* **plugin:** resolve hook CLI through plugin-root launcher ([#3053](https://github.com/headroomlabs-ai/headroom/issues/3053)) ([ef7605f](https://github.com/headroomlabs-ai/headroom/commit/ef7605fdbb82a5c1a75c9192432bc719aa92927d)) +* **plugins/openclaw:** return messages the proxy did not change exactly as they came in ([#3826](https://github.com/headroomlabs-ai/headroom/issues/3826)) ([d1ad189](https://github.com/headroomlabs-ai/headroom/commit/d1ad189819d0dd87137267a17233ab2703eb803c)) +* prevent HF tokenizer downloads in offline mode ([#3783](https://github.com/headroomlabs-ai/headroom/issues/3783)) ([d503c57](https://github.com/headroomlabs-ai/headroom/commit/d503c575eabd6dfed3f390754d391a337340d7d7)) +* **pricing:** add Claude Sonnet 5.5 / Opus 5.5 / Fable 5.1, correct Sonnet 5 rates ([#3841](https://github.com/headroomlabs-ai/headroom/issues/3841)) ([0d99c56](https://github.com/headroomlabs-ai/headroom/commit/0d99c56dc519c4684079a0e1107399068342104d)) +* protect file reads in chained shell commands ([#2668](https://github.com/headroomlabs-ai/headroom/issues/2668)) ([ffc3599](https://github.com/headroomlabs-ai/headroom/commit/ffc359976b09f64388ac9cb8685904c5a5f59708)) +* **providers/vertex:** Vertex route multi-region locations ([#3802](https://github.com/headroomlabs-ai/headroom/issues/3802)) ([a9c1ac5](https://github.com/headroomlabs-ai/headroom/commit/a9c1ac5368798382b0d7e081e910a6df2a728bb0)) +* **proxy/anthropic:** keep tool_result blocks first when neutralizing headroom_retrieve history ([#3874](https://github.com/headroomlabs-ai/headroom/issues/3874)) ([ccd9fff](https://github.com/headroomlabs-ai/headroom/commit/ccd9fffce93cd93d694d78c601ec50eeff0bd256)) +* **proxy/batch:** honor x-headroom-bypass on the batch paths ([#2570](https://github.com/headroomlabs-ai/headroom/issues/2570)) ([9b26a49](https://github.com/headroomlabs-ai/headroom/commit/9b26a49c14eacd8e14385bf8d28c67b2425b3c5c)) +* **proxy:** add same-origin check to /v1/retrieve/tool_call ([#3955](https://github.com/headroomlabs-ai/headroom/issues/3955)) ([2297b50](https://github.com/headroomlabs-ai/headroom/commit/2297b5059f27c0018b15f7857a5b3a7026a68bdd)) +* **proxy:** bill the whole prompt on the gateway path so savings read true ([#3632](https://github.com/headroomlabs-ai/headroom/issues/3632)) ([befdb52](https://github.com/headroomlabs-ai/headroom/commit/befdb52fd565ff990e4ff1463f0550ba7df588b5)) +* **proxy:** carry safeguard_results through SSE resynthesis ([#3958](https://github.com/headroomlabs-ai/headroom/issues/3958)) ([f37ef59](https://github.com/headroomlabs-ai/headroom/commit/f37ef591894c6b89bc25ea628b64b40bdd1037fb)) +* **proxy:** classify Pi Codex Responses alias ([#2583](https://github.com/headroomlabs-ai/headroom/issues/2583)) ([a493f55](https://github.com/headroomlabs-ai/headroom/commit/a493f559a1a85a5462fc3bf3f363d64c3c9444ce)) +* **proxy:** do not cache error replies delivered as http 200 ([#3930](https://github.com/headroomlabs-ai/headroom/issues/3930)) ([1132a54](https://github.com/headroomlabs-ai/headroom/commit/1132a54b21482332f6ffeeebe8c9935a69709afa)) +* **proxy:** drop a tool_reference naming the search tool itself ([#3172](https://github.com/headroomlabs-ai/headroom/issues/3172)) ([b73adaa](https://github.com/headroomlabs-ai/headroom/commit/b73adaa0ce36a2e65a98f5590c46ea555751b445)) +* **proxy:** F3 — per-tenant TOIN learning key ([#404](https://github.com/headroomlabs-ai/headroom/issues/404)) ([f90a56b](https://github.com/headroomlabs-ai/headroom/commit/f90a56b0388f3dcb6455425a3405b40b67da6858)) +* **proxy:** forward operator-listed guarded upstreams through a proxy ([#3804](https://github.com/headroomlabs-ai/headroom/issues/3804)) ([4b7e5d2](https://github.com/headroomlabs-ai/headroom/commit/4b7e5d246e54b856af22cd1a90b47d93b26226cb)) +* **proxy:** freeze and replay the forwarded prefix on Gemini paths ([#3394](https://github.com/headroomlabs-ai/headroom/issues/3394)) ([#3865](https://github.com/headroomlabs-ai/headroom/issues/3865)) ([dd84321](https://github.com/headroomlabs-ai/headroom/commit/dd84321daa0c3c9dca9264098bb396a75d9e52cd)) +* **proxy:** gate the raw request body, not just its Content-Length header ([#3338](https://github.com/headroomlabs-ai/headroom/issues/3338)) ([c946b6b](https://github.com/headroomlabs-ai/headroom/commit/c946b6b0b0ae048c576940d1a57071b84cb05fb6)) +* **proxy:** hard watchdog that dumps and exits when a native call seizes the GIL ([#3180](https://github.com/headroomlabs-ai/headroom/issues/3180)) ([79daeb5](https://github.com/headroomlabs-ai/headroom/commit/79daeb590f46c8f74483e975647c202d09f01fbb)) +* **proxy:** inject memory context past a trailing system message ([#3948](https://github.com/headroomlabs-ai/headroom/issues/3948)) ([3948dbe](https://github.com/headroomlabs-ai/headroom/commit/3948dbe409eba48c065a3ecb2af9fa2f51921787)) +* **proxy:** keep cache_control outside content blocks where the Chat Completions client put it ([#3895](https://github.com/headroomlabs-ai/headroom/issues/3895)) ([a6d6c14](https://github.com/headroomlabs-ai/headroom/commit/a6d6c14cf781a7725e4e8366e8cd86b1e4e44ba9)) +* **proxy:** keep exception text and the proxy token out of client output ([#3851](https://github.com/headroomlabs-ai/headroom/issues/3851)) ([861e94d](https://github.com/headroomlabs-ai/headroom/commit/861e94d8c173bcca1ec4ae4f84a3e7ef477c4a19)) +* **proxy:** keep the newest user message verbatim in cache-mode delta compression ([#3923](https://github.com/headroomlabs-ai/headroom/issues/3923)) ([613ae92](https://github.com/headroomlabs-ai/headroom/commit/613ae9215058c1d5cabc67186f3b47089b22eddb)) +* **proxy:** key rate limits by peer unless the caller authenticated ([#3860](https://github.com/headroomlabs-ai/headroom/issues/3860)) ([6fb7cad](https://github.com/headroomlabs-ai/headroom/commit/6fb7cadfe4c54c2be6b90e9fdea36e91da7b2945)) +* **proxy:** kill the image worker a timed-out call abandons ([#3940](https://github.com/headroomlabs-ai/headroom/issues/3940)) ([793bb85](https://github.com/headroomlabs-ai/headroom/commit/793bb85659d9aa907115ec16ea3356c5459f6299)) +* **proxy:** label plain OpenAI chat traffic openai, not custom ([#3912](https://github.com/headroomlabs-ai/headroom/issues/3912)) ([7df8bd8](https://github.com/headroomlabs-ai/headroom/commit/7df8bd87b54725174e1ea88e2dcd428b66cb608f)) +* **proxy:** log response_content_length in proxy_inbound_response ([#2701](https://github.com/headroomlabs-ai/headroom/issues/2701)) ([cfa479a](https://github.com/headroomlabs-ai/headroom/commit/cfa479af8e551d5d2710debf80adb86fd72b16e5)) +* **proxy:** preserve Bedrock body-limit error dialect ([#3871](https://github.com/headroomlabs-ai/headroom/issues/3871)) ([ffc6edb](https://github.com/headroomlabs-ai/headroom/commit/ffc6edb4ba7a775d2f6440769b626132a66aa7d3)) +* **proxy:** preserve Windows service when Rust core is blocked ([#2989](https://github.com/headroomlabs-ai/headroom/issues/2989)) ([eaa16d9](https://github.com/headroomlabs-ai/headroom/commit/eaa16d9f6343f9d5364863c7522734f6b86b1844)) +* **proxy:** quarantine compression only once half the pool is stuck ([#3932](https://github.com/headroomlabs-ai/headroom/issues/3932)) ([be2b205](https://github.com/headroomlabs-ai/headroom/commit/be2b205c51430236404177bb7d80930a2d299d5a)) +* **proxy:** read HEADROOM_LICENSE and make usage reporting opt-in ([#3857](https://github.com/headroomlabs-ai/headroom/issues/3857)) ([7460389](https://github.com/headroomlabs-ai/headroom/commit/746038991cf2b4dbb31dd2227d607db7bc273da7)) +* **proxy:** reap wrap-spawned proxies once no wrap clients remain ([#3202](https://github.com/headroomlabs-ai/headroom/issues/3202)) ([4227bd2](https://github.com/headroomlabs-ai/headroom/commit/4227bd2419e72a922149d5dfaf416808d8e08139)) +* **proxy:** redact upstream error detail and add opt-in /metrics loopback gate ([#2589](https://github.com/headroomlabs-ai/headroom/issues/2589)) ([a05717f](https://github.com/headroomlabs-ai/headroom/commit/a05717f6ec2b0514ccaa42b9bd1b3c95d3465730)) +* **proxy:** refuse token-less non-loopback binds, gate operator routes ([#3852](https://github.com/headroomlabs-ai/headroom/issues/3852)) ([ee731d7](https://github.com/headroomlabs-ai/headroom/commit/ee731d7ea84503ad844eb2759fcb5bbf04a1ae0e)) +* **proxy:** reset the cc-switch upstream when Claude Official is selected ([#3166](https://github.com/headroomlabs-ai/headroom/issues/3166)) ([fed7281](https://github.com/headroomlabs-ai/headroom/commit/fed7281120af5b476d3911ca1ebf4dc66d5ca1ea)) +* **proxy:** run extension middleware inside the security gate and body ceiling ([#3847](https://github.com/headroomlabs-ai/headroom/issues/3847)) ([1854fd7](https://github.com/headroomlabs-ai/headroom/commit/1854fd7fd3f52bb4cc0b17803de0a04a850eb8f7)) +* **proxy:** run injected memory tools server-side on streaming turns ([#3947](https://github.com/headroomlabs-ai/headroom/issues/3947)) ([1cf4966](https://github.com/headroomlabs-ai/headroom/commit/1cf496612e781ef8d67ff87ee4f78037492f0bc7)) +* **proxy:** share one compression deadline across a Responses request ([#3938](https://github.com/headroomlabs-ai/headroom/issues/3938)) ([4d27d02](https://github.com/headroomlabs-ai/headroom/commit/4d27d024b09fe04f6e32b158063538f084d7febb)) +* **proxy:** skip pricing lookup for passthrough:* models ([#2585](https://github.com/headroomlabs-ai/headroom/issues/2585)) ([f87848c](https://github.com/headroomlabs-ai/headroom/commit/f87848cf78db43dcd4b7e9b6aca3859245348456)) +* **proxy:** stop headroom logger from suppressing propagation to stdout/stderr ([#3096](https://github.com/headroomlabs-ai/headroom/issues/3096)) ([f78e66f](https://github.com/headroomlabs-ai/headroom/commit/f78e66f7cc5e00d5db04c493f702ef4ebe7035b0)) +* **relevance:** bound segment size by max_chars ([#2518](https://github.com/headroomlabs-ai/headroom/issues/2518)) ([0ef7b2a](https://github.com/headroomlabs-ai/headroom/commit/0ef7b2a7fdd7daf4dbb8628c616c28a233ce7910)) +* **reporting:** price the savings tile instead of fabricating $0.00 ([#3821](https://github.com/headroomlabs-ai/headroom/issues/3821)) ([f519fa8](https://github.com/headroomlabs-ai/headroom/commit/f519fa8b97aa9ccef369f5973f1d476af2a65edf)) +* **router:** fall back to built-ins when an external compressor passes through ([#3893](https://github.com/headroomlabs-ai/headroom/issues/3893)) ([2c4b20f](https://github.com/headroomlabs-ai/headroom/commit/2c4b20fcba6da0366c1c637a2a3cc61e843012a7)) +* **router:** keep ccr_retrieve exemption through orchestrator wrappers ([#3915](https://github.com/headroomlabs-ai/headroom/issues/3915)) ([6151ed1](https://github.com/headroomlabs-ai/headroom/commit/6151ed1f52f3d0dd24eb28e0fa666226150a0055)) +* **rust-proxy:** forward request paths verbatim and pin the rustls provider ([#3853](https://github.com/headroomlabs-ai/headroom/issues/3853)) ([9d98ea5](https://github.com/headroomlabs-ai/headroom/commit/9d98ea59082ca6b00be2508f00b85ef90c94f744)) +* **savings:** record tool-schema dollars disjointly beside the folded headline ([#3170](https://github.com/headroomlabs-ai/headroom/issues/3170)) ([c719d4a](https://github.com/headroomlabs-ai/headroom/commit/c719d4af55ebca177c81c98f74531bfaca831366)) +* **savings:** stop scoring unobserved strata against the global mean ([#3128](https://github.com/headroomlabs-ai/headroom/issues/3128)) ([f864525](https://github.com/headroomlabs-ai/headroom/commit/f8645257c074b7e5babd949e705d7ca4e8366ae4)) +* **sdk:** keep tool names on Vercel tool-result parts through the OpenAI round trip ([#3883](https://github.com/headroomlabs-ai/headroom/issues/3883)) ([b715671](https://github.com/headroomlabs-ai/headroom/commit/b7156714acf1f06cd37f642044455e23e2f89d47)) +* **sdk:** preserve Gemini media parts in message conversion instead of dropping the turn ([#3882](https://github.com/headroomlabs-ai/headroom/issues/3882)) ([038c923](https://github.com/headroomlabs-ai/headroom/commit/038c923b4bcd01305f18d993e301b837d668c343)) +* **sdk:** stop JSON-wrapping structured tool_result content in the Anthropic adapter ([#3797](https://github.com/headroomlabs-ai/headroom/issues/3797)) ([2e4a60a](https://github.com/headroomlabs-ai/headroom/commit/2e4a60ae3730fc7ea8f5afebbc29961d5fd0af11)) +* **search:** stop a context line's body from becoming its line marker ([#3788](https://github.com/headroomlabs-ai/headroom/issues/3788)) ([f3f2e00](https://github.com/headroomlabs-ai/headroom/commit/f3f2e0077ae67d492bfc5922e9a49427036e32e0)) +* **security:** create memory stores and other state files owner-only ([#3848](https://github.com/headroomlabs-ai/headroom/issues/3848)) ([5119b6e](https://github.com/headroomlabs-ai/headroom/commit/5119b6eb545e35b617e2d08b183e968d0104d548)) +* **security:** exempt only GET health probes from the proxy token ([#3921](https://github.com/headroomlabs-ai/headroom/issues/3921)) ([d99779d](https://github.com/headroomlabs-ai/headroom/commit/d99779dae69d44c934d2115ffd8ee1f1a6cbb898)) +* **security:** partition the response cache by credential and principal ([#3862](https://github.com/headroomlabs-ai/headroom/issues/3862)) ([ee6cfcc](https://github.com/headroomlabs-ai/headroom/commit/ee6cfcccf6a8944225344c2a468f509783934239)) +* **security:** poll usage only with the operator's own credential ([#3863](https://github.com/headroomlabs-ai/headroom/issues/3863)) ([65e63c0](https://github.com/headroomlabs-ai/headroom/commit/65e63c08835410149bec4acba8e3737366eb0586)) +* **security:** stop forwarding the proxy token to upstream providers ([#3891](https://github.com/headroomlabs-ai/headroom/issues/3891)) ([0147cf0](https://github.com/headroomlabs-ai/headroom/commit/0147cf02207fd76be8a14932bcc65a9709bfdb59)) +* **settings:** report live proxy configuration ([#3177](https://github.com/headroomlabs-ai/headroom/issues/3177)) ([58b1454](https://github.com/headroomlabs-ai/headroom/commit/58b1454282f2b2dee49616c6637fa135ccb885b7)) +* **smart-crusher:** recurse at adaptive array limit ([#3770](https://github.com/headroomlabs-ai/headroom/issues/3770)) ([7790bde](https://github.com/headroomlabs-ai/headroom/commit/7790bdeebda290b9fdbf899df1cbf9a05e0bc5e9)) +* **storage:** page JSONL queries after sorting ([#3872](https://github.com/headroomlabs-ai/headroom/issues/3872)) ([91237ca](https://github.com/headroomlabs-ai/headroom/commit/91237cae70771e8e19cbc06ca65d328ac6cb0878)) +* **subscription:** match the Bearer scheme case-insensitively for the Codex usage poll (RFC 7235) ([#3966](https://github.com/headroomlabs-ai/headroom/issues/3966)) ([fe88461](https://github.com/headroomlabs-ai/headroom/commit/fe884612c07d2e8a4454615ccc5b6d4feed1d7dd)) +* **subscription:** poll with the refreshed credentials-file OAuth token ([#3916](https://github.com/headroomlabs-ai/headroom/issues/3916)) ([2dc9fc2](https://github.com/headroomlabs-ai/headroom/commit/2dc9fc22f09ec85d8e73c916e7c1f6f565d271ec)) +* surface Codex responses traffic in dashboard ([#399](https://github.com/headroomlabs-ai/headroom/issues/399)) ([ecc4967](https://github.com/headroomlabs-ai/headroom/commit/ecc496709b7f2c2657de25d1e3f8de3f226b32e5)) +* **telemetry:** label custom-base chat upstreams from a fixed provider set ([#3759](https://github.com/headroomlabs-ai/headroom/issues/3759)) ([f0ec2bb](https://github.com/headroomlabs-ai/headroom/commit/f0ec2bb37697b1addccb416bd626e7bf3c5ea835)) +* **thinking:** don't read the model's date suffix as its minor version ([#3791](https://github.com/headroomlabs-ai/headroom/issues/3791)) ([afaaaa8](https://github.com/headroomlabs-ai/headroom/commit/afaaaa88049357bac7ec186ea33678f406d02609)) +* **transforms/kompress:** preserve line boundaries and tabular output in Kompress ([#3119](https://github.com/headroomlabs-ai/headroom/issues/3119)) ([fe2ed2b](https://github.com/headroomlabs-ai/headroom/commit/fe2ed2b545e4b3c97d21872232d5446cb54aedc2)) +* **transforms:** judge a Codex exec envelope read by its output ([#3878](https://github.com/headroomlabs-ai/headroom/issues/3878)) ([922924e](https://github.com/headroomlabs-ai/headroom/commit/922924ef44ebd7501ff00923cc0c2007f06cfade)) +* **transforms:** judge the code_aware Kompress fallback in tokens ([#3881](https://github.com/headroomlabs-ai/headroom/issues/3881)) ([6326965](https://github.com/headroomlabs-ai/headroom/commit/63269650462c1a565c259cfe3fc3bd67ac942f5a)) +* **transforms:** keep record-bearing JSON out of Kompress ([#3673](https://github.com/headroomlabs-ai/headroom/issues/3673)) ([#3880](https://github.com/headroomlabs-ai/headroom/issues/3880)) ([8dbbd1d](https://github.com/headroomlabs-ai/headroom/commit/8dbbd1d8967f719acfaa21e2c1668e9f3193dbe8)) +* **wrap:** never reuse a non-Headroom listener on the proxy port ([#3799](https://github.com/headroomlabs-ai/headroom/issues/3799)) ([3ffa57e](https://github.com/headroomlabs-ai/headroom/commit/3ffa57ec25a6f05dff073c0ad0b14c90ce264fd8)) +* **wrap:** never reuse a proxy with incompatible routing config ([#3201](https://github.com/headroomlabs-ai/headroom/issues/3201)) ([f69e246](https://github.com/headroomlabs-ai/headroom/commit/f69e2463a0c29cea7943af5efe4b2a2170a1e863)) +* **wrap:** preserve pre-set ANTHROPIC_BASE_URL as proxy upstream ([#1358](https://github.com/headroomlabs-ai/headroom/issues/1358)) ([bb1ab6a](https://github.com/headroomlabs-ai/headroom/commit/bb1ab6afd35a5c22b6c11b549aba4075fb7791ec)) +* **wrap:** scope Serena's MCP registration to the wrapped project ([#2787](https://github.com/headroomlabs-ai/headroom/issues/2787)) ([#2992](https://github.com/headroomlabs-ai/headroom/issues/2992)) ([8cfeb69](https://github.com/headroomlabs-ai/headroom/commit/8cfeb692ca0cad9598160020a144c5c671cd2fc3)) +* **wrap:** set ANTHROPIC_HOST so `wrap goose` actually proxies Anthropic ([#2619](https://github.com/headroomlabs-ai/headroom/issues/2619)) ([c07fad0](https://github.com/headroomlabs-ai/headroom/commit/c07fad0f37d44b7f6886e6ce774d424463636242)) +* **wrap:** strip -dev from running proxy version in restart check ([#3200](https://github.com/headroomlabs-ai/headroom/issues/3200)) ([bd0296b](https://github.com/headroomlabs-ai/headroom/commit/bd0296b5e7b62b620df7b37b41ce573d57958e71)) + + +### Performance Improvements + +* add savings audit output ([#1211](https://github.com/headroomlabs-ai/headroom/issues/1211)) ([9a72bc0](https://github.com/headroomlabs-ai/headroom/commit/9a72bc024263e2470626fae7f20430db682e0042)) +* **compression/code:** avoid a UTF-8 copy per code block in byte-to-char mapping ([#3169](https://github.com/headroomlabs-ai/headroom/issues/3169)) ([d5e5534](https://github.com/headroomlabs-ai/headroom/commit/d5e5534b4763b14d7710908153e0d6a89423eea9)) +* **image:** OCR and SigLIP each image once, not once per turn ([#3941](https://github.com/headroomlabs-ai/headroom/issues/3941)) ([a8561fb](https://github.com/headroomlabs-ai/headroom/commit/a8561fb4be1bdc17bf8bf86ac97c17651e2820b7)) +* **memory:** bound traffic-learner _persisted_ids with the dedup window ([#3341](https://github.com/headroomlabs-ai/headroom/issues/3341)) ([7e73438](https://github.com/headroomlabs-ai/headroom/commit/7e73438dc5f923c90ad0c1f2f07cb6de221c9f08)) +* **metrics:** cap inbound request-path cardinality to bound memory ([#3340](https://github.com/headroomlabs-ai/headroom/issues/3340)) ([88a1f4e](https://github.com/headroomlabs-ai/headroom/commit/88a1f4e52ba220b5ddb34d94ac0a201391c16027)) +* **proxy:** lighter request path: alias the message snapshot, keep the token cache on re-counts, decompress off the event loop ([#3909](https://github.com/headroomlabs-ai/headroom/issues/3909)) ([c873d13](https://github.com/headroomlabs-ai/headroom/commit/c873d13fe07d09be71d5dcbfd640a78ec5b3f0d7)) +* **tokenizer:** memoize OpenAI token counts like the Anthropic counter ([#3168](https://github.com/headroomlabs-ai/headroom/issues/3168)) ([0a2c80d](https://github.com/headroomlabs-ai/headroom/commit/0a2c80d5207642a5e58ba9eb65e19cd004018642)) + + +### Dependencies + +* bump pyjwt 2.13.0 -> 2.15.1 for CVE-2026-102274 ([#3861](https://github.com/headroomlabs-ai/headroom/issues/3861)) ([37b9c46](https://github.com/headroomlabs-ai/headroom/commit/37b9c46069f11a2b2a477ae4ef25dd33bf359684)) +* Bump source-map-js from 1.2.1 to 1.2.2 in /docs ([#3986](https://github.com/headroomlabs-ai/headroom/issues/3986)) ([e06631a](https://github.com/headroomlabs-ai/headroom/commit/e06631a7a78d18432c90cfb44b30e461424cfd30)) +* Bump source-map-js from 1.2.1 to 1.2.2 in /plugins/openclaw ([#3988](https://github.com/headroomlabs-ai/headroom/issues/3988)) ([4be83b3](https://github.com/headroomlabs-ai/headroom/commit/4be83b35e683df077b11af4c4b37b301c4a65547)) +* Bump source-map-js from 1.2.1 to 1.2.2 in /plugins/opencode ([#3987](https://github.com/headroomlabs-ai/headroom/issues/3987)) ([478dd9e](https://github.com/headroomlabs-ai/headroom/commit/478dd9e0a34cd592fdc0e313019297bc23b17db3)) +* Bump source-map-js from 1.2.1 to 1.2.2 in /sdk/typescript ([#3985](https://github.com/headroomlabs-ai/headroom/issues/3985)) ([62cde35](https://github.com/headroomlabs-ai/headroom/commit/62cde35b939d7405735b72d1e50aa7c1407e5892)) +* bump the npm-minor-patch group across 2 directories with 11 updates ([#3903](https://github.com/headroomlabs-ai/headroom/issues/3903)) ([afe4f4e](https://github.com/headroomlabs-ai/headroom/commit/afe4f4eca58977ae9382dfb2899a8097b46c0909)) +* bump the npm-minor-patch group across 3 directories with 11 updates ([#3910](https://github.com/headroomlabs-ai/headroom/issues/3910)) ([ff1a0d6](https://github.com/headroomlabs-ai/headroom/commit/ff1a0d69f4a83f7c5583a7af2d2961025587801e)) +* bump webpki-roots from 0.26.11 to 1.0.8 ([#3905](https://github.com/headroomlabs-ai/headroom/issues/3905)) ([fee53b4](https://github.com/headroomlabs-ai/headroom/commit/fee53b4daa3a0ac2b2378a692278f24f0062d584)) +* drop the crewai extra to remove chromadb from the lockfile ([#3870](https://github.com/headroomlabs-ai/headroom/issues/3870)) ([59b8cef](https://github.com/headroomlabs-ai/headroom/commit/59b8cefc24e48539974787e0677298f335b2720e)) + + +### Code Refactoring + +* **ccr:** read the expansion query through extract_user_query ([#2707](https://github.com/headroomlabs-ai/headroom/issues/2707)) ([dcec845](https://github.com/headroomlabs-ai/headroom/commit/dcec845851d8202ce3361022a14b3716fdeb59c0)) +* **wrap:** generate goose/openhands/openclaude from a WrapTarget registry ([#3800](https://github.com/headroomlabs-ai/headroom/issues/3800)) ([de4cf7e](https://github.com/headroomlabs-ai/headroom/commit/de4cf7e7a1ece51ce6c60d25bc34f46897e972c9)) + ## [0.39.1](https://github.com/headroomlabs-ai/headroom/compare/v0.39.0...v0.39.1) (2026-09-26) diff --git a/Cargo.lock b/Cargo.lock index 87429c2f72..ef78954566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -269,7 +269,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.20", + "thiserror 2.0.21", "v_frame", "y4m", ] @@ -469,9 +469,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.5.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -606,9 +606,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf162725183ec0df77a9dc82ce22d9ba0b6ea7f376e8ec4f6b695727c70698" +checksum = "9bb0deb40a69de59f7809251cfe1ab16de135f42c49d222d1202b4d0ce19720f" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -645,9 +645,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ecf7989e381d0cbb7ca06104140d940bf38d8eb9004bd3a12d23df03e3d363" +checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1" dependencies = [ "base64-simd", "bytes", @@ -866,13 +866,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -944,9 +944,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -1612,9 +1612,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" dependencies = [ "bit-set", "regex-automata", @@ -1661,9 +1661,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1811,7 +1811,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "tracing-futures", @@ -1932,8 +1932,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -1972,7 +1970,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tiktoken-rs", "tokenizers 0.23.2", "toml", @@ -1998,7 +1996,7 @@ dependencies = [ "headroom-core", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2011,6 +2009,7 @@ dependencies = [ "aws-sigv4", "aws-smithy-runtime-api", "axum", + "base64 0.22.1", "bytes", "bytesize", "clap", @@ -2038,7 +2037,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-rustls", "tokio-stream", @@ -2050,7 +2049,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots 1.0.8", "wiremock", ] @@ -2078,7 +2077,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tower", "tracing", @@ -2118,7 +2117,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "ureq", "windows-sys 0.61.2", ] @@ -2255,16 +2254,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http 1.5.0", "http-body 1.0.1", + "httparse", "hyper", "ipnet", "libc", @@ -2694,9 +2694,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ "hashbrown 0.17.1", ] @@ -3306,7 +3306,7 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3437,7 +3437,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3458,7 +3458,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3584,7 +3584,7 @@ dependencies = [ "rand 0.9.4", "rand_chacha", "simd_helpers", - "thiserror 2.0.20", + "thiserror 2.0.21", "v_frame", "wasm-bindgen", ] @@ -3657,9 +3657,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed" +checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9" dependencies = [ "arcstr", "combine", @@ -3688,7 +3688,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3796,7 +3796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3881,9 +3881,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -4299,11 +4299,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4319,9 +4319,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -4353,15 +4353,14 @@ dependencies = [ [[package]] name = "tiktoken-rs" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +checksum = "2aeff724640cfe13037336ddf35befdffd2909cbdb65cf041cc8a4cf8c584cfa" dependencies = [ "anyhow", "base64 0.22.1", "bstr", "fancy-regex", - "lazy_static", "regex", "rustc-hash", ] @@ -4459,7 +4458,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -4493,7 +4492,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -4528,9 +4527,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4897,7 +4896,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1 0.10.6", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4915,7 +4914,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -5482,7 +5481,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] diff --git a/Dockerfile b/Dockerfile index f38f0cd7c9..fe7f4e3e6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -198,11 +198,15 @@ VOLUME ${RUNTIME_HOME}/.headroom EXPOSE 8787 +# Shell form so ${HEADROOM_PORT} expands at probe time: `headroom deploy +# --port N` sets it in the container env, and an exec-form CMD would keep +# probing 8787 and report a working deployment as unhealthy (issue #2432). HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD ["curl", "--fail", "--silent", "http://127.0.0.1:8787/readyz"] + CMD curl --fail --silent "http://127.0.0.1:${HEADROOM_PORT:-8787}/readyz" ENTRYPOINT ["headroom", "proxy"] -CMD ["--port", "8787"] +# Keep host and port defaults in the CLI so HEADROOM_HOST and HEADROOM_PORT +# remain authoritative for the published image. FROM ${DISTROLESS_IMAGE} AS runtime-slim @@ -223,11 +227,16 @@ ENV HEADROOM_HOST=127.0.0.1 \ EXPOSE 8787 +# This stage is distroless, so there is no shell to expand ${HEADROOM_PORT}. +# Resolve the port inside Python instead, keeping exec form (issue #2432). +# `or` rather than a get() default so an empty HEADROOM_PORT falls back the +# same way `${HEADROOM_PORT:-8787}` does in the shell-form stage above. HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/readyz', timeout=5)"] + CMD ["python3", "-c", "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + (os.environ.get('HEADROOM_PORT') or '8787') + '/readyz', timeout=5)"] ENTRYPOINT ["python3", "-m", "headroom.cli", "proxy"] -CMD ["--port", "8787"] +# Keep host and port defaults in the CLI so HEADROOM_HOST and HEADROOM_PORT +# remain authoritative for the published image. # Default published image remains python-slim runtime FROM runtime-slim-base AS runtime diff --git a/README.md b/README.md index 5ae98a97d0..a478cf7f8e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@

Quickstart · + Headroom for Teams · Install · Proof · Agents · @@ -37,6 +38,10 @@ chunks, files, and conversation history — before it reaches the LLM. Same answers, fraction of the tokens. Compression runs on your machine; no prompt or file content is sent anywhere to be compressed. +

+ Headroom Enterprise for teams: 3 months free with a 12-month plan. Claim the perk. +

+
Headroom compressing a 10,144 token log dump to 1,260 tokens while preserving the FATAL line
10,144 → 1,260 tokens. The same FATAL found. @@ -121,9 +126,10 @@ print(f"Saved {result.tokens_saved} tokens ({result.compression_ratio:.0%})") Launch a wrapped agent session each time, so the setup runs. `headroom wrap` starts a local proxy, installs **[Serena](https://github.com/oraios/serena)** for semantic code navigation, and launches the agent configured to route through -Headroom. Serena is registered at user scope (for Claude Code, in -`~/.claude.json`), so it stays available in your other projects until you run -`headroom unwrap`. Skip it with `--code-memory none`. +Headroom. For Claude Code, Serena is registered for the wrapped project only +(as a `local`-scope MCP server in `~/.claude.json`). Use +`--code-memory-scope user` to make it available in every project, or +`--code-memory none` to skip it. `headroom unwrap` removes either registration. The `headroom` CLI ships only in the PyPI package. The npm `headroom-ai` package is the TypeScript SDK — a library you import @@ -205,9 +211,14 @@ picks the level: ```bash headroom learn --verbosity # dry run — preview what it found -headroom learn --verbosity --apply # save it; the proxy picks it up +headroom learn --verbosity --apply # save it and apply it to the running proxy ``` +A proxy in cache mode (the default) never re-reads the learned level, because a +level that changes mid-conversation would bust the prompt cache. There `--apply` +pins it with `HEADROOM_VERBOSITY_LEVEL`; set that variable yourself to keep it +after a restart. + **Measuring it.** Output savings are counterfactual — we never see what the model *would* have written — so Headroom reports an estimate with a confidence range and labels it as one: @@ -235,7 +246,7 @@ Saved** card then reads `measured` rather than `estimated`, with the band. | Copilot CLI | ✅ | starts proxy + launches | | VS Code Copilot | ✅ | transparent proxy; keeps the selected model | | OpenClaw | ✅ | installs as a ContextEngine plugin | -| OpenCode | ✅ | injects config · starts proxy + launches | +| OpenCode | ✅ | injects config · starts proxy + launches [^third-party-upstream] | | Cline | ✅ | starts proxy + injects config | | Continue | ✅ | starts proxy + injects config | | Goose | ✅ | starts proxy + launches | @@ -246,12 +257,16 @@ Saved** card then reads `measured` rather than `estimated`, with the band. | Kimi CLI | ✅ | OAuth bearer forwarded — log in once | | ZCode | ✅ | starts the proxy and prints base URLs for ZCode settings | +[^third-party-upstream]: `headroom wrap` routes OpenAI-compatible traffic to the default upstream (`https://api.openai.com/v1`). For a third-party OpenAI-compatible provider (DeepSeek, Together, OpenRouter, a self-hosted gateway), pass `--openai-api-url https://api.deepseek.com/v1` — or set `OPENAI_TARGET_API_URL` before wrapping — otherwise your provider key is sent to OpenAI and rejected with `401 Incorrect API key provided`. See [OpenCode + DeepSeek](docs/content/docs/opencode-deepseek.mdx). + Any OpenAI-compatible client works through `headroom proxy`. MCP-native clients: `headroom mcp install`. Undo durable wrapping with `headroom unwrap ` (`claude`, `copilot`, `codex`, `grok`, `kimi`, `omp`, `opencode`, `openclaw`, `zcode`). Registry authors should use the canonical [`server.json`](server.json) rather than reconstructing the `headroom mcp serve` contract from prose. +For Anthropic `/v1/messages`, `--mode cache` skips automatic `--memory` context injection so the provider prefix remains stable. OpenAI chat/responses and Gemini append memory to the live-zone tail. Use `--mode token` when you need automatic memory context on the Anthropic path. +
GitHub Copilot CLI subscription mode @@ -610,26 +625,30 @@ regresses a compression ratio across real workloads rather than only our own tes corpus. Turn it off with `HEADROOM_BEACON=off`, the `DO_NOT_TRACK=1` convention, or -`--offline`. The full field list is in +`HEADROOM_OFFLINE=1`. The full field list is in [the proxy docs](https://docs.headroomlabs.ai/docs/proxy). ## Headroom for teams -Headroom OSS is built for individual developers: run `headroom proxy` or -`headroom wrap` on your laptop and start cutting tokens in minutes, free and -local-first. +Headroom OSS is built for one developer on one laptop. Headroom Enterprise is built +for the whole team. + +| | Open source | Enterprise | +|---|---|---| +| Savings | Compression, plus tool search on Anthropic requests (on by default) | Also: tool search for every model, harness tuning, extra compressors | +| Model routing | Rule-based and opt-in: turn it on with `HEADROOM_MODEL_ROUTER_ENABLED=1` and write the rules in `HEADROOM_MODEL_ROUTES` | Automatic: a cheaper model per request when quality allows, with a shadow mode that shows the savings before you turn it on | +| Security | — | Detect and strip prompt injection and risky tool calls | +| Rollout | Per laptop | Managed or in your VPC, SSO, central config, org-wide savings | +| Support | GitHub, Discord | Dedicated engineers, onboarding, priority response | -Running it across an engineering org is a different job — a shared always-on -deployment, centralised config and version rollout, org-wide savings dashboards, -SSO and access control, air-gapped and VPC installs, and someone to call. We help -companies with that, self-hosted with support or fully managed. +**3 months of Enterprise free.** Companies that sign a 12-month Enterprise plan get +their first 3 months free. One perk per company. -If your team is spending real money on LLM tokens — Claude Code, Codex, Cursor, -or agents running in CI — email **[hello@headroomlabs.ai](mailto:hello@headroomlabs.ai)** -with your stack and rough monthly LLM spend. +**[Claim 3 months free →](https://headroom-perks.vercel.app/?source=github&utm_source=github&utm_medium=readme&utm_content=teams)** +· or email [hello@headroomlabs.ai](mailto:hello@headroomlabs.ai) with your stack and +monthly LLM spend. -Everything in this repo stays open source under Apache 2.0. The managed offering -is for teams that would rather have it deployed, supported and scaled for them. +Everything in this repo stays open source under Apache 2.0. ## Documentation diff --git a/RUST_DEV.md b/RUST_DEV.md index 2b5e76c199..8952667753 100644 --- a/RUST_DEV.md +++ b/RUST_DEV.md @@ -84,6 +84,7 @@ curl -si http://127.0.0.1:8787/v1/models | Flag | Env var | Default | Notes | | --- | --- | --- | --- | | `--listen` | `HEADROOM_PROXY_LISTEN` | `0.0.0.0:8787` | bind address | +| `--metrics-require-loopback` | `HEADROOM_PROXY_METRICS_REQUIRE_LOOPBACK` | `false` | when set, `/metrics` is served only to loopback peers (403 otherwise); recommended on non-loopback binds | | `--upstream` | `HEADROOM_PROXY_UPSTREAM` | (required) | base URL the proxy forwards to | | `--upstream-timeout` | | `600s` | end-to-end request timeout (long for streams) | | `--upstream-connect-timeout` | | `10s` | TCP/TLS connect timeout | @@ -91,6 +92,80 @@ curl -si http://127.0.0.1:8787/v1/models | `--log-level` | | `info` | `RUST_LOG`-style filter | | `--rewrite-host` / `--no-rewrite-host` | | rewrite | rewrite Host to upstream (default) | | `--graceful-shutdown-timeout` | | `30s` | wait for in-flight on SIGTERM/SIGINT | +| `--stats` | `HEADROOM_PROXY_STATS` | `true` | native savings stats + `/dashboard` (below) | +| `--stats-path` | `HEADROOM_PROXY_STATS_PATH` | `~/.headroom/native_stats.json` | ledger persistence (honours `HEADROOM_WORKSPACE_DIR`) | + +### Savings stats & dashboard (native) + +The Rust proxy records per-request savings/cost telemetry on every +lane it forwards — Anthropic `/v1/messages`, OpenAI Chat/Responses, +all four native Bedrock routes, and Vertex `rawPredict` / +`streamRawPredict` — and serves it locally (never tunnelled +upstream): + +```bash +curl -s http://127.0.0.1:8787/stats | jq '.session' # since-boot totals +curl -s http://127.0.0.1:8787/stats | jq '.lifetime_by_model' +curl -s "http://127.0.0.1:8787/stats/timeseries?bucket=day" | jq '.points[-7:]' +curl -s "http://127.0.0.1:8787/stats/events?limit=5" # recent-request feed +open http://127.0.0.1:8787/dashboard # embedded UI, zero deps +``` + +Recording is deferred until the response is fully observed, so +streaming output/cache token counts come from the real SSE / +EventStream usage frames. USD figures come from the vendored LiteLLM +price table (`data/model_prices_and_context_window.json`); a model +missing from it records $0 and logs one WARN — refresh via +`scripts/refresh_model_limits.sh`. Input and output are priced +separately (`input_cost_usd` / `output_cost_usd`) because they bill +at different rates — output is typically 4-5x input, so a spend +figure that counted input alone would understate a short-prompt, +long-answer turn by most of its cost. `total_cost_usd` is the sum +and is what `/dashboard` shows as "Spend". Cache savings are NET: the +cache-read discount minus the cache-write premium (writes bill +above list price), floored at $0 per request — a warm-up turn that +only writes is not counted as negative savings, and gross-read +figures that overstate the benefit are never shown. Failed upstreams count as +failures and accrue no savings — as do proxy-side rejections +(missing AWS credentials, SigV4/ADC failures, oversized bodies), so +an operator-side outage reads as a failure spike rather than as +zero traffic. Aggregates (lifetime, per-model, 48 h hourly + +~13 mo daily buckets) persist as one atomic snapshot written off +the hot path every 10 s and on shutdown. + +> **What reads 0 today.** Spend, token counts, and prompt-cache +> savings are live. `tokens_saved` / `compression_savings_usd` are +> wired end-to-end but will report 0 on real traffic until the +> live-zone per-type compressors are implemented — the dispatcher +> currently returns `NoCompression` for every well-formed body +> (`live_zone_mode_with_valid_body_returns_no_compression_pr_b2` +> pins that invariant). Nothing needs changing here when they land; +> the numbers start flowing on their own. + +> **No auth; exposure is split by tier.** These endpoints bind +> wherever `--listen` points (default `0.0.0.0:8787`) and carry no +> auth, so the payload is split the same way the Python proxy splits +> its own `/stats`: +> +> - **Aggregates** — spend, tokens, per-model/provider rollups, +> history — are served to anyone, like the Prometheus `/metrics` +> endpoint next door. Same data class, same stance. +> - **Per-request rows** — `recent_requests`, all of `/stats/events` +> — plus the ledger's filesystem path are served **only to local +> callers**: loopback peer *and* a loopback `Host` header (the +> second is the DNS-rebinding defence — a rebound request reaches +> the proxy *from* loopback, so the peer check alone doesn't hold). +> Remote callers get `recent_requests: null` and a 404 from +> `/stats/events`; the dashboard says so rather than erroring. +> +> A request id + model + timestamp is a different sensitivity tier +> from a counter, and `/metrics` never exposes it — so "same as +> /metrics" isn't a licence to hand it to the open internet. Opening +> the local-only tier to a trusted reverse proxy is tracked in +> [#1959](https://github.com/headroomlabs-ai/headroom/issues/1959), +> which is settling that policy for the Python dashboard first; this +> surface should adopt it rather than grow a second scheme. +> `--stats=false` removes the routes entirely. ### Picking the next port: invocation telemetry diff --git a/SECURITY.md b/SECURITY.md index 7bdd352146..ba5f902b6e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,13 @@ | Version | Supported | | ------- | ------------------ | -| 0.27.x (latest) | :white_check_mark: | -| < 0.27.x | :x: | +| Latest release on PyPI | :white_check_mark: | +| Any earlier release | :x: | + +Headroom releases frequently and only the most recent release is supported. Security +fixes ship in a new release rather than being backported to earlier ones, so upgrading is +the remediation path. Check what you are running with `headroom --version`, and compare it +against [the current release](https://pypi.org/project/headroom-ai/). ## Reporting a Vulnerability @@ -38,8 +43,16 @@ When using Headroom: 1. **API Keys**: Never commit API keys. Use environment variables. 2. **Proxy Exposure**: Don't expose the proxy server to the public internet without authentication -3. **Log Files**: Be aware that request logs may contain sensitive information +3. **Log Files**: Headroom always writes an operational log to `~/.headroom/logs/proxy.log`. + The opt-in `--log-file` request log, and especially `--log-messages`, additionally write + request and response *content* to disk. Be aware that both may contain sensitive information. 4. **Budget Limits**: Set budget limits to prevent unexpected costs +5. **Workspace directory**: `~/.headroom` holds both credential material and cached + request content. Treat it as sensitive — especially on shared or multi-user hosts. + Note that it is not the only location: with memory enabled, extracted memories default + to a project-local `.headroom/` directory beside the code you ran the agent in. See + [Security Model](https://headroom-docs.vercel.app/docs/security-model) for exactly what + is written where. ### Scope @@ -53,14 +66,46 @@ The following are out of scope: - Issues in dependencies (report these to the upstream project) - Social engineering attacks -## Security Features - -Headroom includes several security features: - -- **No credential storage**: We never store or log API keys -- **Passthrough mode**: Sensitive content passes through unchanged by default -- **Input validation**: All inputs are validated before processing -- **Safe defaults**: Security-conscious defaults out of the box +## Security Posture + +Headroom is a local proxy that sits between your agents and your LLM providers, so +it necessarily handles both your traffic and your credentials. The +[Security Model](https://headroom-docs.vercel.app/docs/security-model) documents this in +full — what is written to disk, with what permissions and retention, what leaves the +host, and what the local admin surface does and does not allow. The summary: + +- **Provider API keys are forwarded, not persisted.** Keys supplied via environment + variables or request headers are used to authenticate the upstream call and are not + written to disk or emitted to logs. The one exception is deliberate: the + `ANTHROPIC_TARGET_API_HEADERS` / `OPENAI_TARGET_API_HEADERS` header maps, if you set + them through the dashboard settings GUI, are persisted to `~/.headroom/settings.json` + in plaintext — and a header map is where a gateway key typically goes. +- **Some credentials *are* stored, by design.** `headroom copilot-auth login` persists a + GitHub Copilot OAuth refresh token to `~/.headroom/copilot_auth.json` (relocatable via + `$HEADROOM_COPILOT_AUTH_FILE`). It is plaintext JSON, written with `0600` permissions + on a best-effort basis. +- **Cached request content is written to disk.** CCR stores pre-compression originals — + tool outputs, file contents, retrieved chunks — in a local SQLite database + (`~/.headroom/ccr_store.db`, `0600`) so they can be retrieved after compression. Entries + are plaintext and are not encrypted at rest. Set `HEADROOM_CCR_BACKEND=memory` if your + deployment cannot accept disk persistence. +- **Anonymous session summaries are uploaded by default.** The `HEADROOM_BEACON` telemetry + beacon is opt-*out*: it POSTs content-free session counters, a random per-install UUID, + version, OS, and architecture to Headroom Labs. No prompts, completions, file contents, + or paths are included. Disable with `HEADROOM_BEACON=off`, `DO_NOT_TRACK=1`, or + `HEADROOM_OFFLINE=1`. This is separate from `HEADROOM_TELEMETRY`, which is opt-in and + stays on the machine. +- **Passthrough mode**: Sensitive content passes through unchanged by default. +- **Operational logs are written unconditionally.** `~/.headroom/logs/proxy.log` + (10 MB × 5 rotations) is always on and also carries the admin audit stream. The + request log (`--log-file`) and full message logging (`--log-messages`) are opt-in and + write request/response content to a path you choose. +- **Local control surfaces are loopback-gated.** The `/admin/*`, `/debug/*`, + `/v1/retrieve*`, `/v1/telemetry*`, `/v1/toin/*`, and `/settings*` routes require a + loopback client address *and* a loopback `Host:` header, plus a same-origin check on + the mutating ones. This is a local-trust model: any process on the same host is + trusted. Setting `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` deliberately widens + the settings and stats surface beyond loopback. Thank you for helping keep Headroom and its users safe! @@ -70,28 +115,20 @@ The following public upstream advisories remain unresolved. They are included in `uv.lock` through optional extras; the presence of a package in that universal lockfile does not mean it is installed with every Headroom installation. -### CrewAI / ChromaDB - -The `crewai` extra brings in ChromaDB through CrewAI. The locked ChromaDB 1.1.1 -and the latest published version, 1.5.9, are affected by: - -- [GHSA-f4j7-r4q5-qw2c](https://github.com/advisories/GHSA-f4j7-r4q5-qw2c): - pre-authentication code injection through model repository configuration. -- [GHSA-36p7-vc44-83pf](https://github.com/advisories/GHSA-36p7-vc44-83pf): - code injection through model repository configuration with `trust_remote_code`. -- [GHSA-2wm9-hf6c-p5cr](https://github.com/advisories/GHSA-2wm9-hf6c-p5cr): - cross-tenant access to collection data. -- [GHSA-xph7-9rjv-w5fr](https://github.com/advisories/GHSA-xph7-9rjv-w5fr): - missing resource-scope checks in `SimpleRBACAuthorizationProvider`. - -There is no published patched release. The upstream authorization fix -[chroma-core/chroma#7602](https://github.com/chroma-core/chroma/pull/7602) -is still open. Upgrading CrewAI alone also retains ChromaDB. Headroom's CrewAI -integration wraps tools; it does not start a ChromaDB server or configure its -authorization. Deployments that separately expose ChromaDB must not rely on its -affected authorization for tenant isolation. Keep it inaccessible to untrusted -clients and do not allow untrusted model repository or `trust_remote_code` -configuration. These exposure restrictions are mitigations, not upstream fixes. +### CrewAI / ChromaDB (no longer locked) + +Headroom no longer provides a `crewai` extra, so CrewAI and ChromaDB are not in +`uv.lock` and are never installed by Headroom. The CrewAI integration uses +whichever CrewAI the user installs (`pip install headroom-ai crewai`), and that +installation owns its dependency exposure. Every current CrewAI release requires +ChromaDB, which has unpatched advisories in its server +([GHSA-f4j7-r4q5-qw2c](https://github.com/advisories/GHSA-f4j7-r4q5-qw2c), +[GHSA-36p7-vc44-83pf](https://github.com/advisories/GHSA-36p7-vc44-83pf), +[GHSA-2wm9-hf6c-p5cr](https://github.com/advisories/GHSA-2wm9-hf6c-p5cr), +[GHSA-xph7-9rjv-w5fr](https://github.com/advisories/GHSA-xph7-9rjv-w5fr)). +Headroom's integration only wraps tools and never starts a ChromaDB server. If you +run ChromaDB as a server, keep it inaccessible to untrusted clients and follow +those advisories. ### Voice training / Accelerate @@ -111,6 +148,6 @@ file-type validation. The proposed fixes merging; the latter also explicitly leaves the named-pipe denial of service unfixed. Keep the alert open until a released fix covers both cases. -Dependabot ignores only the reviewed unpatched ranges (ChromaDB through 1.5.9 -and Accelerate through 1.15.0). Later releases remain eligible for review. These +Dependabot ignores only the reviewed unpatched Accelerate range (through 1.15.0). +Later releases remain eligible for review. These update exceptions do not remediate the advisories or dismiss vulnerability alerts. diff --git a/benchmarks/tool_search_vs_native/README.md b/benchmarks/tool_search_vs_native/README.md new file mode 100644 index 0000000000..f55c3ae332 --- /dev/null +++ b/benchmarks/tool_search_vs_native/README.md @@ -0,0 +1,85 @@ +# Claude Code tool search: native vs Headroom + +A live A/B benchmark. Each run is a fresh, isolated Claude Code session with its own +throwaway `HOME`, a new copy of `fixture/`, and six fake enterprise MCP servers +(`mcp_server.py`: GitHub, Jira, Slack, Datadog, PagerDuty and Confluence, 63 tools). +Every session does the same task: + +1. Read Jira BENCH-42. +2. Fix the pricing bug. +3. Run the tests. +4. Search the Datadog logs. +5. Comment on the ticket. +6. Post to Slack. + +Usage is read from Claude Code's own transcript, which records what the API billed, +subagents included. + +```bash +python3 benchmarks/tool_search_vs_native/run.py --arms native,hr_client,hr_server,hr_hot --reps 3 --warmup +python3 benchmarks/tool_search_vs_native/run.py ... --context-kb 200 # lookups deep in a long session +``` + +The script needs `ANTHROPIC_API_KEY` (or `~/env.txt`) and the repo's `.venv`. A full run +of four arms with three reps and a warmup costs about $1 short and $5 long, on Sonnet 5.5. + +| Arm | Setup | +|---|---| +| `native` | Claude Code straight to api.anthropic.com, with its own ToolSearch (on by default). | +| `eager` | Claude Code straight to Anthropic with `ENABLE_TOOL_SEARCH=false`, so nothing is deferred. | +| `hr_client` | Claude Code through Headroom with `ENABLE_TOOL_SEARCH=true` (what `headroom wrap` sets today). | +| `hr_server` | Claude Code through Headroom with `ENABLE_TOOL_SEARCH=false`. Headroom defers tools on Anthropic's side. | +| `hr_hot` | As `hr_server`, but the MCP tools a developer uses routinely stay loaded (`HEADROOM_TOOL_SEARCH_CORE_TOOLS`). | + +## Results (2026-10-06, Claude Code 2.1.290, Sonnet 5.5, n=3 after warmup, median) + +Run on macOS 26.6 (arm64) with Python 3.12.13 (the repo's `.venv`) and real Anthropic API +calls. Commands: + +```bash +python3 benchmarks/tool_search_vs_native/run.py --arms native,hr_hot,hr_client --reps 3 --warmup +python3 benchmarks/tool_search_vs_native/run.py --arms native,hr_hot,hr_client --reps 3 --warmup --context-kb 200 +``` + +A run passes only if Claude Code exits 0, the fixture tests pass, and the session called +Jira `get_issue`, Datadog `search_logs`, Jira `add_comment` and Slack `post_message`. +All 73 recorded runs pass under that rule. + +| | native | hr_client | hr_server | hr_hot | +|---|---|---|---|---| +| short task, before fixes | $0.059 | $0.062 | $0.081 | $0.049 | +| long task, before fixes | $0.283 | $0.323 | $0.350 | $0.426 | +| long task, **after fixes** | $0.284 | **$0.284** | — | **$0.255 (−10%)** | +| short task, **after fixes** | $0.061 | $0.062 | — | **$0.046 (−25%)** | + +The "after" rows held in every run: on the long task `hr_hot` cost $0.254–0.256 and +`native` $0.282–0.284. + +### What the runs showed + +- **A tool lookup costs a re-read of the whole conversation.** That holds for Claude + Code's ToolSearch, which takes an extra turn, and for Anthropic's server-side search, + which takes an extra sampling pass inside the same call (usage sums the iterations). A + schema that stays loaded costs about 0.1× its size per turn. So keeping a routinely + used tool loaded is far cheaper than looking it up. +- **Server-side search without names searches blindly.** Claude Code tells the model the + deferred tool names, so it loads everything it needs in one `select:`. Headroom's + server-side deferral gives no names, and the model ran 2–5 regex searches. Each search + pulled in about 4.7K tokens of schemas, uncached. +- **Bug: cache mode broke when Claude Code re-serialized its last reply.** Headroom + records the model's raw reply as the end of the previous turn. Claude Code sends that + reply back edited: it strips a leading `cd &&` from Bash commands and drops + `caller`. The history then read as diverged, Headroom forwarded the raw originals over + its earlier compressed messages, and the whole conversation was re-written to cache: + 66.8K tokens on every long `hr_hot` run. Fixed in `headroom/cache/prefix_tracker.py` + (`_client_rewrote_reply_delta`). Set `HEADROOM_DEBUG_PREFIX_MISMATCH=` to log and + dump where a history diverges. +- **Bug: compressing small MCP results cost turns.** The Jira ticket (a few hundred + tokens) was compressed and lost its description, so the model called + `headroom_retrieve` and then re-fetched the ticket: two extra turns, about +15% on the + long task. MCP results under `HEADROOM_MCP_RESULT_MIN_CHARS` (default 4000) are now + forwarded verbatim, in both Anthropic `tool_result` blocks and OpenAI `role: "tool"` + messages. + +The fake MCP schemas are terse compared with real servers such as GitHub's official one, +so absolute savings here understate a real catalog. diff --git a/.claude/.headroom_wrap_settings.lock b/benchmarks/tool_search_vs_native/fixture/shop/__init__.py similarity index 100% rename from .claude/.headroom_wrap_settings.lock rename to benchmarks/tool_search_vs_native/fixture/shop/__init__.py diff --git a/benchmarks/tool_search_vs_native/fixture/shop/pricing.py b/benchmarks/tool_search_vs_native/fixture/shop/pricing.py new file mode 100644 index 0000000000..e8fd07c5a4 --- /dev/null +++ b/benchmarks/tool_search_vs_native/fixture/shop/pricing.py @@ -0,0 +1,16 @@ +"""Checkout pricing.""" + +from __future__ import annotations + + +def apply_discount(price: float, percent: float) -> float: + """Return ``price`` after a ``percent`` discount, rounded to cents.""" + discounted = price * (1 - percent / 100) + discounted = discounted * (1 - percent / 100) + return round(discounted, 2) + + +def order_total(items: list[tuple[float, int]], coupon_percent: float = 0) -> float: + """Sum ``(unit_price, quantity)`` lines and apply an optional coupon.""" + subtotal = sum(price * qty for price, qty in items) + return apply_discount(subtotal, coupon_percent) if coupon_percent else round(subtotal, 2) diff --git a/benchmarks/tool_search_vs_native/fixture/tests/__init__.py b/benchmarks/tool_search_vs_native/fixture/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchmarks/tool_search_vs_native/fixture/tests/test_pricing.py b/benchmarks/tool_search_vs_native/fixture/tests/test_pricing.py new file mode 100644 index 0000000000..9ea611ada4 --- /dev/null +++ b/benchmarks/tool_search_vs_native/fixture/tests/test_pricing.py @@ -0,0 +1,18 @@ +import unittest + +from shop.pricing import apply_discount, order_total + + +class PricingTest(unittest.TestCase): + def test_ten_percent(self): + self.assertEqual(apply_discount(100.0, 10), 90.0) + + def test_zero(self): + self.assertEqual(apply_discount(59.99, 0), 59.99) + + def test_order_total_with_coupon(self): + self.assertEqual(order_total([(20.0, 2), (10.0, 1)], coupon_percent=10), 45.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/benchmarks/tool_search_vs_native/mcp_server.py b/benchmarks/tool_search_vs_native/mcp_server.py new file mode 100644 index 0000000000..4c934215f5 --- /dev/null +++ b/benchmarks/tool_search_vs_native/mcp_server.py @@ -0,0 +1,697 @@ +#!/usr/bin/env python3 +"""Deterministic stdio MCP server standing in for one enterprise service. + + python3 mcp_server.py + +Exposes realistic, full-size tool schemas (GitHub, Jira, Slack, Datadog, PagerDuty, +Confluence) so a coding agent sees a catalog the size real enterprise setups have. +Every tools/call is appended to calls.jsonl so the benchmark can check the task was done. +Standard library only; speaks newline-delimited JSON-RPC 2.0 (MCP stdio transport). +""" + +from __future__ import annotations + +import json +import sys + +SERVICE = sys.argv[1] +CALL_LOG = sys.argv[2] + +# (name, description, {param: (type, description)}, required) +CATALOG = { + "github": [ + ( + "search_repositories", + "Search GitHub repositories by keyword, language, topic, stars or owner. Returns repository metadata including default branch, visibility, open issue count and last push time.", + { + "query": ( + "string", + "GitHub search syntax, e.g. 'billing language:python org:acme'", + ), + "sort": ("string", "stars, forks, updated or best-match"), + "per_page": ("integer", "Results per page, 1-100"), + "page": ("integer", "Page number, starting at 1"), + }, + ["query"], + ), + ( + "get_repository", + "Get full metadata for one repository: description, topics, default branch, branch protection summary, languages, license and permissions of the caller.", + { + "owner": ("string", "Repository owner, user or organization"), + "repo": ("string", "Repository name"), + }, + ["owner", "repo"], + ), + ( + "list_pull_requests", + "List pull requests in a repository with filters for state, base branch, head branch and author. Includes review state, mergeability and CI status summary.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "state": ("string", "open, closed or all"), + "base": ("string", "Filter by base branch"), + "author": ("string", "Filter by author login"), + }, + ["owner", "repo"], + ), + ( + "get_pull_request", + "Get a pull request including body, commits, changed files with patch hunks, review comments, requested reviewers and status checks.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "number": ("integer", "Pull request number"), + }, + ["owner", "repo", "number"], + ), + ( + "create_pull_request", + "Open a pull request from a head branch into a base branch. Supports draft pull requests, maintainer edits and an initial set of reviewers and labels.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "title": ("string", "Pull request title"), + "head": ("string", "Branch with your changes"), + "base": ("string", "Branch to merge into"), + "body": ("string", "Markdown description"), + "draft": ("boolean", "Open as a draft"), + }, + ["owner", "repo", "title", "head", "base"], + ), + ( + "merge_pull_request", + "Merge a pull request using merge, squash or rebase. Fails if required checks or reviews are missing unless the caller can bypass branch protection.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "number": ("integer", "Pull request number"), + "method": ("string", "merge, squash or rebase"), + "commit_title": ("string", "Title for the merge commit"), + }, + ["owner", "repo", "number"], + ), + ( + "list_issues", + "List issues with filters for labels, assignee, milestone, state and creation date. Pull requests are excluded unless requested.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "labels": ("string", "Comma-separated label names"), + "assignee": ("string", "Assignee login, 'none' or '*'"), + "state": ("string", "open, closed or all"), + }, + ["owner", "repo"], + ), + ( + "create_issue", + "Create an issue with title, body, labels, assignees and milestone.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "title": ("string", "Issue title"), + "body": ("string", "Markdown body"), + "labels": ("array", "Label names"), + "assignees": ("array", "Assignee logins"), + }, + ["owner", "repo", "title"], + ), + ( + "add_issue_comment", + "Add a comment to an issue or pull request conversation.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "number": ("integer", "Issue or pull request number"), + "body": ("string", "Markdown comment"), + }, + ["owner", "repo", "number", "body"], + ), + ( + "get_file_contents", + "Read a file or list a directory at a ref. Returns decoded content for text files and metadata for binaries.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "path": ("string", "Path within the repository"), + "ref": ("string", "Branch, tag or commit SHA"), + }, + ["owner", "repo", "path"], + ), + ( + "list_commits", + "List commits on a branch or touching a path, newest first, with author, message and verification status.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "sha": ("string", "Branch or commit to start from"), + "path": ("string", "Only commits touching this path"), + "since": ("string", "ISO 8601 timestamp"), + }, + ["owner", "repo"], + ), + ( + "get_workflow_runs", + "List GitHub Actions workflow runs with status, conclusion, triggering event, branch and timing.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "workflow": ("string", "Workflow file name or ID"), + "branch": ("string", "Filter by branch"), + "status": ("string", "queued, in_progress, completed, failure, success"), + }, + ["owner", "repo"], + ), + ( + "get_job_logs", + "Download the log of one workflow job, optionally only the failing step.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "job_id": ("integer", "Workflow job ID"), + "failed_only": ("boolean", "Return only the failing step"), + }, + ["owner", "repo", "job_id"], + ), + ( + "create_branch", + "Create a branch from an existing ref.", + { + "owner": ("string", "Repository owner"), + "repo": ("string", "Repository name"), + "branch": ("string", "New branch name"), + "from_ref": ("string", "Branch, tag or SHA to branch from"), + }, + ["owner", "repo", "branch"], + ), + ( + "search_code", + "Search code across repositories with GitHub code search syntax. Returns file paths, repository and matching fragments.", + {"query": ("string", "Code search query"), "per_page": ("integer", "Results per page")}, + ["query"], + ), + ], + "jira": [ + ( + "search_issues", + "Search Jira issues with JQL. Returns key, summary, status, assignee, priority, labels and updated time for each match.", + { + "jql": ("string", "JQL query, e.g. 'project = BENCH AND status = Open'"), + "max_results": ("integer", "Maximum issues to return"), + "fields": ("array", "Fields to include"), + }, + ["jql"], + ), + ( + "get_issue", + "Get one Jira issue with description, comments, status history, linked issues, components, fix versions and custom fields.", + { + "issue_key": ("string", "Issue key, e.g. BENCH-42"), + "expand": ("string", "Extra sections such as changelog or renderedFields"), + }, + ["issue_key"], + ), + ( + "create_issue", + "Create a Jira issue in a project with type, summary, description, priority, labels and assignee.", + { + "project": ("string", "Project key"), + "issue_type": ("string", "Bug, Task, Story or Epic"), + "summary": ("string", "One-line summary"), + "description": ("string", "Description in Jira markdown"), + "priority": ("string", "Highest, High, Medium, Low"), + "labels": ("array", "Labels"), + }, + ["project", "issue_type", "summary"], + ), + ( + "update_issue", + "Update fields on an issue: summary, description, labels, priority, assignee or custom fields.", + {"issue_key": ("string", "Issue key"), "fields": ("object", "Field name to new value")}, + ["issue_key", "fields"], + ), + ( + "add_comment", + "Add a comment to a Jira issue. Supports Jira markdown and @mentions.", + { + "issue_key": ("string", "Issue key, e.g. BENCH-42"), + "body": ("string", "Comment text"), + }, + ["issue_key", "body"], + ), + ( + "transition_issue", + "Move an issue through its workflow, for example To Do -> In Progress -> Done, with an optional resolution and comment.", + { + "issue_key": ("string", "Issue key"), + "transition": ("string", "Transition name or ID"), + "resolution": ("string", "Resolution when closing"), + "comment": ("string", "Comment to add with the transition"), + }, + ["issue_key", "transition"], + ), + ( + "assign_issue", + "Assign an issue to a user, or unassign it.", + { + "issue_key": ("string", "Issue key"), + "assignee": ("string", "Account ID, or null to unassign"), + }, + ["issue_key"], + ), + ( + "list_sprints", + "List sprints on a board with state and dates.", + { + "board_id": ("integer", "Agile board ID"), + "state": ("string", "active, future or closed"), + }, + ["board_id"], + ), + ( + "get_sprint_issues", + "List issues in a sprint with status and story points.", + {"sprint_id": ("integer", "Sprint ID"), "jql": ("string", "Extra JQL filter")}, + ["sprint_id"], + ), + ( + "link_issues", + "Link two issues with a relationship such as blocks, relates to or duplicates.", + { + "inward_issue": ("string", "Inward issue key"), + "outward_issue": ("string", "Outward issue key"), + "link_type": ("string", "Link type name"), + }, + ["inward_issue", "outward_issue", "link_type"], + ), + ( + "add_worklog", + "Log time spent on an issue.", + { + "issue_key": ("string", "Issue key"), + "time_spent": ("string", "Duration such as 2h 30m"), + "comment": ("string", "Work description"), + }, + ["issue_key", "time_spent"], + ), + ( + "get_project", + "Get a project's issue types, components, versions and workflow scheme.", + {"project": ("string", "Project key")}, + ["project"], + ), + ], + "slack": [ + ( + "post_message", + "Post a message to a Slack channel or thread. Supports mrkdwn formatting, blocks and unfurl settings.", + { + "channel": ("string", "Channel name like #eng-alerts or channel ID"), + "text": ("string", "Message text in mrkdwn"), + "thread_ts": ("string", "Reply in this thread"), + }, + ["channel", "text"], + ), + ( + "list_channels", + "List public and private channels the bot can see, with member counts and topics.", + { + "types": ("string", "public_channel, private_channel, mpim, im"), + "limit": ("integer", "Maximum channels"), + }, + [], + ), + ( + "get_channel_history", + "Read recent messages from a channel, newest first.", + { + "channel": ("string", "Channel name or ID"), + "limit": ("integer", "Maximum messages"), + "oldest": ("string", "Only messages after this timestamp"), + }, + ["channel"], + ), + ( + "get_thread_replies", + "Read every reply in a message thread.", + { + "channel": ("string", "Channel ID"), + "thread_ts": ("string", "Parent message timestamp"), + }, + ["channel", "thread_ts"], + ), + ( + "search_messages", + "Search messages across the workspace with Slack search modifiers such as in:, from: and before:.", + {"query": ("string", "Search query"), "count": ("integer", "Results to return")}, + ["query"], + ), + ( + "add_reaction", + "Add an emoji reaction to a message.", + { + "channel": ("string", "Channel ID"), + "timestamp": ("string", "Message timestamp"), + "name": ("string", "Emoji name without colons"), + }, + ["channel", "timestamp", "name"], + ), + ( + "get_user_profile", + "Get a user's profile, title, time zone and status.", + {"user": ("string", "User ID or email")}, + ["user"], + ), + ( + "set_channel_topic", + "Set a channel's topic.", + {"channel": ("string", "Channel ID"), "topic": ("string", "New topic")}, + ["channel", "topic"], + ), + ( + "upload_file", + "Upload a file or snippet to one or more channels.", + { + "channels": ("array", "Channel IDs"), + "content": ("string", "File content"), + "filename": ("string", "File name"), + "title": ("string", "Title"), + }, + ["channels", "content"], + ), + ( + "schedule_message", + "Schedule a message for later delivery.", + { + "channel": ("string", "Channel ID"), + "text": ("string", "Message text"), + "post_at": ("integer", "Unix timestamp"), + }, + ["channel", "text", "post_at"], + ), + ], + "datadog": [ + ( + "query_metrics", + "Query a metric time series with Datadog query syntax over a time range. Returns points, unit and aggregation.", + { + "query": ("string", "e.g. avg:billing.api.latency{env:prod} by {endpoint}"), + "from_ts": ("integer", "Start, Unix seconds"), + "to_ts": ("integer", "End, Unix seconds"), + }, + ["query", "from_ts", "to_ts"], + ), + ( + "search_logs", + "Search logs with Datadog log query syntax, newest first, including attributes and tags.", + { + "query": ("string", "Log query, e.g. service:billing status:error"), + "from_time": ("string", "Relative or absolute start, e.g. now-1h"), + "limit": ("integer", "Maximum log events"), + }, + ["query"], + ), + ( + "list_monitors", + "List monitors with state, query, thresholds, tags and notification targets.", + { + "name": ("string", "Filter by name"), + "tags": ("string", "Comma-separated tags"), + "group_states": ("string", "alert, warn, no data, ok"), + }, + [], + ), + ( + "get_monitor", + "Get one monitor's definition, current state per group and recent state changes.", + {"monitor_id": ("integer", "Monitor ID")}, + ["monitor_id"], + ), + ( + "mute_monitor", + "Mute a monitor, optionally for one scope and until a time.", + { + "monitor_id": ("integer", "Monitor ID"), + "scope": ("string", "Scope to mute"), + "end": ("integer", "Unix time to unmute"), + }, + ["monitor_id"], + ), + ( + "list_dashboards", + "List dashboards with title, author and URL.", + {"filter": ("string", "Title filter")}, + [], + ), + ( + "get_apm_traces", + "Search APM traces by service, resource and duration, with span breakdown.", + { + "service": ("string", "Service name"), + "resource": ("string", "Resource name"), + "min_duration_ms": ("integer", "Minimum duration"), + }, + ["service"], + ), + ( + "list_incidents", + "List incidents with severity, state, commander and timeline summary.", + { + "state": ("string", "active, stable or resolved"), + "severity": ("string", "SEV-1 to SEV-5"), + }, + [], + ), + ( + "create_event", + "Post an event to the event stream, for example a deploy marker.", + { + "title": ("string", "Event title"), + "text": ("string", "Event body"), + "tags": ("array", "Tags"), + }, + ["title", "text"], + ), + ( + "get_service_dependencies", + "Get upstream and downstream dependencies of a service from APM.", + {"service": ("string", "Service name"), "env": ("string", "Environment")}, + ["service"], + ), + ], + "pagerduty": [ + ( + "list_incidents", + "List PagerDuty incidents with urgency, status, service and assignments.", + { + "statuses": ("array", "triggered, acknowledged, resolved"), + "service_ids": ("array", "Service IDs"), + "since": ("string", "ISO 8601 start"), + }, + [], + ), + ( + "get_incident", + "Get one incident with timeline, alerts, notes and responders.", + {"incident_id": ("string", "Incident ID")}, + ["incident_id"], + ), + ( + "acknowledge_incident", + "Acknowledge an incident on behalf of the caller.", + {"incident_id": ("string", "Incident ID")}, + ["incident_id"], + ), + ( + "resolve_incident", + "Resolve an incident with an optional resolution note.", + {"incident_id": ("string", "Incident ID"), "note": ("string", "Resolution note")}, + ["incident_id"], + ), + ( + "list_oncalls", + "List who is on call for schedules and escalation policies.", + { + "schedule_ids": ("array", "Schedule IDs"), + "since": ("string", "Start time"), + "until": ("string", "End time"), + }, + [], + ), + ( + "add_incident_note", + "Add a note to an incident timeline.", + {"incident_id": ("string", "Incident ID"), "content": ("string", "Note text")}, + ["incident_id", "content"], + ), + ( + "list_services", + "List services with escalation policy and integrations.", + {"query": ("string", "Name filter")}, + [], + ), + ( + "create_incident", + "Trigger a new incident on a service.", + { + "service_id": ("string", "Service ID"), + "title": ("string", "Incident title"), + "urgency": ("string", "high or low"), + "body": ("string", "Details"), + }, + ["service_id", "title"], + ), + ], + "confluence": [ + ( + "search_pages", + "Search Confluence pages with CQL, returning title, space, excerpt and last editor.", + { + "cql": ("string", "CQL query, e.g. space = ENG and text ~ 'discount'"), + "limit": ("integer", "Maximum results"), + }, + ["cql"], + ), + ( + "get_page", + "Get a page body in storage format with version, ancestors and labels.", + { + "page_id": ("string", "Page ID"), + "expand": ("string", "body.storage, version, ancestors"), + }, + ["page_id"], + ), + ( + "create_page", + "Create a page in a space, optionally under a parent page.", + { + "space": ("string", "Space key"), + "title": ("string", "Page title"), + "body": ("string", "Body in storage format"), + "parent_id": ("string", "Parent page ID"), + }, + ["space", "title", "body"], + ), + ( + "update_page", + "Update a page body; requires the next version number.", + { + "page_id": ("string", "Page ID"), + "title": ("string", "Title"), + "body": ("string", "New body"), + "version": ("integer", "Next version number"), + }, + ["page_id", "body", "version"], + ), + ( + "add_page_comment", + "Add a footer comment to a page.", + {"page_id": ("string", "Page ID"), "body": ("string", "Comment body")}, + ["page_id", "body"], + ), + ( + "list_spaces", + "List spaces with key, name, type and homepage.", + {"type": ("string", "global or personal"), "limit": ("integer", "Maximum spaces")}, + [], + ), + ( + "get_page_children", + "List child pages of a page.", + {"page_id": ("string", "Page ID")}, + ["page_id"], + ), + ( + "add_label", + "Add labels to a page.", + {"page_id": ("string", "Page ID"), "labels": ("array", "Labels")}, + ["page_id", "labels"], + ), + ], +} + +ISSUE = { + "key": "BENCH-42", + "summary": "Discount applied twice at checkout", + "status": "Open", + "priority": "High", + "description": ( + "Customers with a 10% coupon are charged 81% of the price instead of 90%. " + "The bug is in shop/pricing.py, function apply_discount. " + "tests/test_pricing.py reproduces it. When fixed, comment on this issue with a one-line " + "summary of the fix and post a short note to the #eng-alerts Slack channel." + ), + "comments": [], +} + + +def schema(params: dict, required: list) -> dict: + props = {} + for name, (typ, desc) in params.items(): + p = {"type": typ, "description": desc} + if typ == "array": + p["items"] = {"type": "string"} + props[name] = p + return {"type": "object", "properties": props, "required": required} + + +TOOLS = [ + {"name": n, "description": d, "inputSchema": schema(p, r)} for n, d, p, r in CATALOG[SERVICE] +] + + +def call(name: str, args: dict) -> str: + with open(CALL_LOG, "a") as fh: + fh.write(json.dumps({"service": SERVICE, "tool": name, "args": args}) + "\n") + if SERVICE == "jira" and name == "get_issue": + return json.dumps(ISSUE if args.get("issue_key") == "BENCH-42" else {"error": "not found"}) + if SERVICE == "jira" and name == "search_issues": + return json.dumps( + {"issues": [{k: ISSUE[k] for k in ("key", "summary", "status", "priority")}]} + ) + return json.dumps({"ok": True, "service": SERVICE, "tool": name}) + + +def main() -> None: + for line in sys.stdin: + try: + msg = json.loads(line) + except ValueError: + continue + mid, method = msg.get("id"), msg.get("method") + if mid is None: # notification + continue + if method == "initialize": + result = { + "protocolVersion": msg.get("params", {}).get("protocolVersion", "2025-06-18"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": SERVICE, "version": "1.0.0"}, + } + elif method == "tools/list": + result = {"tools": TOOLS} + elif method == "tools/call": + p = msg.get("params", {}) + result = { + "content": [{"type": "text", "text": call(p.get("name"), p.get("arguments") or {})}] + } + elif method == "ping": + result = {} + else: + sys.stdout.write( + json.dumps( + { + "jsonrpc": "2.0", + "id": mid, + "error": {"code": -32601, "message": f"unknown method {method}"}, + } + ) + + "\n" + ) + sys.stdout.flush() + continue + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result}) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/benchmarks/tool_search_vs_native/run.py b/benchmarks/tool_search_vs_native/run.py new file mode 100644 index 0000000000..4ca39d7142 --- /dev/null +++ b/benchmarks/tool_search_vs_native/run.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Claude Code native tool search vs Headroom server-side tool search. + + python3 run.py --arms native,hr_server --reps 3 --model claude-sonnet-5-5 + +Every run is a fresh, isolated Claude Code (throwaway HOME, fresh copy of the fixture +repo, six fake enterprise MCP servers) doing the same task: read a Jira ticket, fix the +bug, run the tests, comment on the ticket, post to Slack. Usage is read from Claude +Code's own transcript (what the API billed), so the numbers include subagents. + +Arms: + native Claude Code straight to api.anthropic.com, its own ToolSearch (default on) + eager Claude Code straight to Anthropic with ENABLE_TOOL_SEARCH=false (no deferral) + hr_client Claude Code -> Headroom, ENABLE_TOOL_SEARCH=true (today's `headroom wrap`) + hr_server Claude Code -> Headroom, ENABLE_TOOL_SEARCH=false; Headroom defers server-side + hr_hot as hr_server, but the MCP tools this developer used before stay loaded + (HEADROOM_TOOL_SEARCH_CORE_TOOLS): no lookup at all for routine tools + hr_ext as hr_server, plus the proxy env given with --proxy-env KEY=VALUE + (repeatable), e.g. to measure a proxy extension + +Needs ANTHROPIC_API_KEY (or ~/env.txt). Results go to results//. +""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +import shutil +import socket +import statistics +import subprocess +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +SERVERS = ["github", "jira", "slack", "datadog", "pagerduty", "confluence"] + +TASK = ( + "Jira ticket BENCH-42 reports a pricing bug in this repository. Look the ticket up in Jira, " + "fix the bug, and run the tests with `python3 -m unittest`. Then search Datadog logs for " + "errors from service billing in the last hour, add a comment on BENCH-42 with a one-line " + "summary of the fix and the Datadog result, and post a short note about the fix to the " + "#eng-alerts Slack channel. Do not ask questions; just do it." +) + +# Headroom's default resident set (headroom/proxy/helpers.py _TOOL_SEARCH_CORE_TOOLS) plus the +# MCP tools a developer doing this kind of work has used before: a learned working set. +CORE = [ + "bash", + "bash_background", + "bash_background_output", + "bash_background_wait", + "bash_background_kill", + "read", + "write", + "edit", + "multiedit", + "apply_patch", + "glob", + "grep", + "task", + "todowrite", + "todoread", + "webfetch", + "question", + "skill", + "toolsearch", +] +HOT = [ + "mcp__jira__get_issue", + "mcp__jira__add_comment", + "mcp__slack__post_message", + "mcp__datadog__search_logs", +] +PROXY_ENV = { + "hr_client": {}, + "hr_server": {}, + "hr_hot": {"HEADROOM_TOOL_SEARCH_CORE_TOOLS": ",".join(CORE + HOT)}, + # As hr_server plus whatever --proxy-env adds, e.g. a proxy extension under test. + "hr_ext": {}, +} + +ALLOWED = ["Bash", "Read", "Edit", "Write", "Glob", "Grep", "ToolSearch"] + [ + f"mcp__{s}" for s in SERVERS +] + +# Billed input-equivalent weights (Anthropic): cache write 5m 1.25x, 1h 2x, read 0.1x. +W_5M, W_1H, W_READ = 1.25, 2.0, 0.1 + + +def api_key() -> str: + if os.environ.get("ANTHROPIC_API_KEY"): + return os.environ["ANTHROPIC_API_KEY"] + for line in Path.home().joinpath("env.txt").read_text().splitlines(): + line = line.strip().removeprefix("export ").strip() + if line.startswith("ANTHROPIC_API_KEY="): + return line.split("=", 1)[1].strip().strip("'\"") + sys.exit("ANTHROPIC_API_KEY not found") + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def start_proxy(out: Path, key: str, name: str, extra: dict) -> tuple[subprocess.Popen, int]: + port = free_port() + ws = out / f"headroom-workspace-{name}" + ws.mkdir(parents=True, exist_ok=True) + env = { + **os.environ, + "HEADROOM_WORKSPACE_DIR": str(ws), + "HEADROOM_TOOL_SEARCH": "1", + "HEADROOM_TELEMETRY": "off", + "ANTHROPIC_API_KEY": key, + **extra, + } + env.pop("ANTHROPIC_BASE_URL", None) + log = open(out / f"proxy-{name}.log", "w") + proc = subprocess.Popen( + [str(REPO / ".venv/bin/headroom"), "proxy", "--port", str(port)], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + cwd=ws, + ) + for _ in range(120): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return proc, port + except OSError: + time.sleep(0.5) + proc.kill() + sys.exit(f"proxy did not start; see {out / f'proxy-{name}.log'}") + + +def usage_from_transcripts(home: Path) -> dict: + seen, calls = set(), [] + for f in glob.glob(str(home / ".claude/projects/*/**/*.jsonl"), recursive=True): + for line in open(f): + try: + r = json.loads(line) + except ValueError: + continue + m = r.get("message") or {} + if m.get("role") != "assistant" or not m.get("usage") or m.get("id") in seen: + continue + seen.add(m.get("id")) + u = m["usage"] + cc = u.get("cache_creation") or {} + w1h = cc.get("ephemeral_1h_input_tokens", 0) + w5m = cc.get("ephemeral_5m_input_tokens", u.get("cache_creation_input_tokens", 0) - w1h) + tool_names = [ + b.get("name") + for b in m.get("content") or [] + if isinstance(b, dict) and b.get("type") in ("tool_use", "server_tool_use") + ] + calls.append( + { + "input": u.get("input_tokens", 0), + "w5m": w5m, + "w1h": w1h, + "read": u.get("cache_read_input_tokens", 0), + "output": u.get("output_tokens", 0), + "sidechain": bool(r.get("isSidechain")), + "tools": tool_names, + } + ) + t = {k: sum(c[k] for c in calls) for k in ("input", "w5m", "w1h", "read", "output")} + t["api_calls"] = len(calls) + t["toolsearch_calls"] = sum(c["tools"].count("ToolSearch") for c in calls) + t["toolsearch_only_turns"] = sum( + 1 for c in calls if c["tools"] and set(c["tools"]) == {"ToolSearch"} + ) + t["server_searches"] = sum( + 1 for c in calls for n in c["tools"] if n and n.startswith("tool_search_tool") + ) + t["billed_input_equiv"] = round( + t["input"] + W_5M * t["w5m"] + W_1H * t["w1h"] + W_READ * t["read"] + ) + return t + + +def check_task(work: Path, calls_log: Path) -> dict: + tests = subprocess.run( + [sys.executable, "-m", "unittest", "-q"], cwd=work, capture_output=True, text=True + ) + calls = ( + [json.loads(line) for line in calls_log.read_text().splitlines()] + if calls_log.exists() + else [] + ) + did = lambda svc, tool: any(c["service"] == svc and c["tool"] == tool for c in calls) # noqa: E731 + return { + "tests_pass": tests.returncode == 0, + "read_ticket": did("jira", "get_issue"), # a search result has no description + "searched_logs": did("datadog", "search_logs"), + "commented": did("jira", "add_comment"), + "posted_slack": did("slack", "post_message"), + "mcp_calls": len(calls), + } + + +def run_one( + arm: str, rep: int, out: Path, key: str, model: str, port: int | None, context_kb: int +) -> dict: + d = out / f"{arm}-{rep}" + home, work = d / "home", d / "work" + home.mkdir(parents=True) + shutil.copytree(HERE / "fixture", work) + task = TASK + if context_kb: # put the MCP lookups deep in the session, where real ones happen + (work / "docs").mkdir() + (work / "docs/ARCHITECTURE.md").write_text(architecture_doc(context_kb)) + task = "First read docs/ARCHITECTURE.md in full (all of it, in chunks if needed). " + TASK + subprocess.run(["git", "init", "-q"], cwd=work) + subprocess.run(["git", "add", "-A"], cwd=work) + subprocess.run( + ["git", "-c", "user.email=b@b", "-c", "user.name=b", "commit", "-qm", "init"], cwd=work + ) + calls_log = d / "mcp_calls.jsonl" + cfg = { + "mcpServers": { + s: {"command": sys.executable, "args": [str(HERE / "mcp_server.py"), s, str(calls_log)]} + for s in SERVERS + } + } + (d / "mcp.json").write_text(json.dumps(cfg)) + + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("ANTHROPIC_", "CLAUDE_", "ENABLE_TOOL_SEARCH", "HEADROOM_")) + } + env.update( + { + "HOME": str(home), + "ANTHROPIC_API_KEY": key, + "DISABLE_AUTOUPDATER": "1", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "DISABLE_TELEMETRY": "1", + } + ) + if arm == "eager": + env["ENABLE_TOOL_SEARCH"] = "false" + elif arm in PROXY_ENV: + env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}" + env["ENABLE_TOOL_SEARCH"] = "true" if arm == "hr_client" else "false" + + cmd = [ + "claude", + "-p", + task, + "--model", + model, + "--mcp-config", + str(d / "mcp.json"), + "--strict-mcp-config", + "--output-format", + "json", + "--allowedTools", + *ALLOWED, + ] + t0 = time.time() + p = subprocess.run(cmd, cwd=work, env=env, capture_output=True, text=True, timeout=900) + wall = time.time() - t0 + (d / "stdout.json").write_text(p.stdout) + (d / "stderr.txt").write_text(p.stderr) + try: + cc = json.loads(p.stdout) + except ValueError: + cc = {} + row = { + "arm": arm, + "rep": rep, + "wall_s": round(wall, 1), + "exit": p.returncode, + "cc_cost_usd": cc.get("total_cost_usd"), + "cc_turns": cc.get("num_turns"), + **usage_from_transcripts(home), + **check_task(work, calls_log), + } + # A session that errored out does not pass, even if it got the actions done. + row["task_ok"] = row["exit"] == 0 and all( + row[k] for k in ("tests_pass", "read_ticket", "searched_logs", "commented", "posted_slack") + ) + return row + + +def architecture_doc(kb: int) -> str: + """Deterministic, non-repetitive prose so compression cannot trivially erase it.""" + import random + + rng = random.Random(42) + words = ( + "ledger invoice coupon tenant region shard replica queue retry idempotency webhook settlement " + "refund currency rounding audit partition latency throughput cache eviction migration rollout " + "flag canary checkout cart session token gateway schema index backfill reconciliation" + ).split() + out, size, n = ["# Billing platform architecture\n"], 0, 0 + while size < kb * 1024: + n += 1 + para = f"\n## Section {n}\n" + " ".join(rng.choice(words) for _ in range(120)) + ".\n" + out.append(para) + size += len(para) + return "".join(out) + + +def summarize(rows: list[dict]) -> str: + keys = [ + "task_ok", + "cc_cost_usd", + "billed_input_equiv", + "output", + "api_calls", + "toolsearch_only_turns", + "server_searches", + "read", + "w5m", + "w1h", + "input", + "wall_s", + ] + lines = ["| arm | n | " + " | ".join(keys) + " |", "|" + "---|" * (len(keys) + 2)] + for arm in dict.fromkeys(r["arm"] for r in rows): + rs = [r for r in rows if r["arm"] == arm] + cells = [] + for k in keys: + vals = [r[k] for r in rs if r.get(k) is not None] + if k == "task_ok": + cells.append(f"{sum(vals)}/{len(rs)}") + elif not vals: + cells.append("-") + else: + med = statistics.median(vals) + cells.append( + f"{med:.3f}" + if k == "cc_cost_usd" + else f"{med:,.0f}" + if k != "wall_s" + else f"{med:.0f}" + ) + lines.append(f"| {arm} | {len(rs)} | " + " | ".join(cells) + " |") + return "\n".join(lines) + "\n\n(median per run)" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--arms", default="native,hr_server,hr_client,eager") + ap.add_argument("--reps", type=int, default=3) + ap.add_argument("--model", default="claude-sonnet-5-5") + ap.add_argument( + "--context-kb", type=int, default=0, help="add a doc of this size to read before the task" + ) + ap.add_argument( + "--warmup", + action="store_true", + help="run each arm once first and discard it (warms caches)", + ) + ap.add_argument( + "--proxy-env", + action="append", + default=[], + metavar="KEY=VALUE", + help="extra proxy environment for the hr_ext arm (repeatable)", + ) + ap.add_argument("--out", default=str(HERE / "results" / time.strftime("%Y%m%d-%H%M%S"))) + a = ap.parse_args() + out = Path(a.out) + out.mkdir(parents=True, exist_ok=True) + key = api_key() + arms = a.arms.split(",") + PROXY_ENV["hr_ext"] = dict(kv.split("=", 1) for kv in a.proxy_env) + proxies = {arm: start_proxy(out, key, arm, PROXY_ENV[arm]) for arm in arms if arm in PROXY_ENV} + rows = [] + try: + if a.warmup: + for arm in arms: + run_one(arm, 0, out, key, a.model, proxies.get(arm, (None, None))[1], a.context_kb) + for rep in range(1, a.reps + 1): # interleave arms so drift hits every arm alike + for arm in arms: + row = run_one( + arm, rep, out, key, a.model, proxies.get(arm, (None, None))[1], a.context_kb + ) + rows.append(row) + print(json.dumps(row), flush=True) + (out / "rows.jsonl").open("a").write(json.dumps(row) + "\n") + finally: + for proc, _ in proxies.values(): + proc.terminate() + s = summarize(rows) + (out / "summary.md").write_text(s + "\n") + print(s) + + +if __name__ == "__main__": + main() diff --git a/crates/headroom-core/Cargo.toml b/crates/headroom-core/Cargo.toml index 4b0d04cbba..031fe9268f 100644 --- a/crates/headroom-core/Cargo.toml +++ b/crates/headroom-core/Cargo.toml @@ -203,6 +203,10 @@ tempfile = "3" name = "tokenizer" harness = false +[[bench]] +name = "live_zone_dispatch" +harness = false + [[bench]] name = "ccr_store" harness = false diff --git a/crates/headroom-core/benches/live_zone_dispatch.rs b/crates/headroom-core/benches/live_zone_dispatch.rs new file mode 100644 index 0000000000..3afbeb537c --- /dev/null +++ b/crates/headroom-core/benches/live_zone_dispatch.rs @@ -0,0 +1,248 @@ +//! Latency benchmark for the PR-B4 live-zone dispatch arms. +//! +//! Measures `compress_anthropic_live_zone` end to end — body parse, +//! live-zone walk, content-type detection, byte-threshold gate, and the +//! per-arm compressor — over representative payload shapes: +//! +//! - `source_code_2kb` / `source_code_20kb`: generated Python routed to +//! the CodeAwareCompressor arm (the tree-sitter parse dominates). +//! - `plain_text_8kb`: prose routed to the Kompress arm. What this case +//! actually measures depends on the machine's Hugging Face cache: +//! with the Kompress model cache-resident it benchmarks a real ONNX +//! inference; on a cold cache `Kompress::from_cache` resolves to a +//! deterministic NoOp and the number is detection + gate cost only. +//! Both are legitimate measurements — record which state applied +//! alongside any published number. +//! - `below_threshold_prose`: a payload under every byte threshold — +//! the cost of the gate itself, i.e. the overhead every small block +//! pays whether or not compression ever fires. +//! +//! Deliberately NOT wired into CI — benchmarks in shared CI runners +//! generate noise, not signal. Run manually: +//! +//! ```text +//! cargo bench -p headroom-core --bench live_zone_dispatch +//! ``` +//! +//! # Windows: `ORT_DYLIB_PATH` +//! +//! On Windows with a warm model cache, ONNX Runtime's shared-library +//! resolution can deadlock inside `ort` init unless `ORT_DYLIB_PATH` +//! points at the onnxruntime library (see +//! `docs/content/docs/troubleshooting.mdx`, "Windows ML DLL" entry). A +//! hung benchmark is strictly worse than a refused one, so on Windows +//! this harness exits early with instructions when the variable is +//! missing AND the Kompress model cache is warm — the only state where +//! the hang is reachable. Cache-cold it proceeds (no ONNX session is +//! ever created; the plain_text case then measures the deterministic +//! no-op path, a valid mode in its own right). Other platforms resolve +//! the library normally and are not gated. + +use std::hint::black_box; + +use criterion::{criterion_group, Criterion, Throughput}; +use headroom_core::transforms::live_zone::DEFAULT_MODEL; +use headroom_core::transforms::{compress_anthropic_live_zone, AuthMode}; +use serde_json::json; + +/// Build the standard single-`tool_result` Anthropic body around `text` +/// — the same shape the dispatch integration tests use (duplicated, not +/// shared: benches and integration tests are independent targets). +fn body_with_tool_result(text: &str) -> Vec { + serde_json::to_vec(&json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 64, + "system": "you are a helpful assistant", + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_dispatch_bench", + "content": text, + }], + }], + })) + .expect("bench body serializes") +} + +/// Syntactically valid Python that detects as `SourceCode`, sized to at +/// least `target_bytes`. Same generator style as the dispatch tests' +/// `python_module_source`, parameterized by byte target instead of +/// function count so the two size cases are explicit at the call site. +fn python_source(target_bytes: usize) -> String { + let mut code = String::from( + "\"\"\"Example data-processing module used by the dispatch bench.\"\"\"\n\n\ + import json\nimport os\nfrom typing import Any, Optional\n\n\n", + ); + let mut i = 0usize; + while code.len() < target_bytes { + code.push_str(&format!( + "def process_record_{i}(record: dict) -> dict:\n \ + \"\"\"Normalize record {i} and compute its derived fields.\"\"\"\n \ + result = dict(record)\n \ + result[\"index\"] = {i}\n \ + result[\"doubled\"] = record.get(\"value\", 0) * 2\n \ + result[\"source\"] = \"batch\"\n \ + if result[\"doubled\"] > 100:\n \ + result[\"flag\"] = \"high\"\n \ + else:\n \ + result[\"flag\"] = \"low\"\n \ + return result\n\n\n" + )); + i += 1; + } + code +} + +/// Varied natural-language prose that detects as `PlainText`, sized to +/// at least `target_bytes`. Sentence shape varies so this is prose to +/// the detector, not a single repeated token run. +fn prose(target_bytes: usize) -> String { + const WORDS: &[&str] = &[ + "the", + "release", + "shipped", + "after", + "review", + "and", + "every", + "service", + "reported", + "healthy", + "metrics", + "while", + "operators", + "watched", + "dashboards", + "during", + "rollout", + "windows", + "before", + "traffic", + "returned", + "to", + "baseline", + "levels", + "overnight", + ]; + let mut text = String::with_capacity(target_bytes + 64); + let mut in_sentence = 0u32; + for (n, word) in WORDS.iter().cycle().enumerate() { + if text.len() >= target_bytes { + break; + } + text.push_str(word); + in_sentence += 1; + // Vary sentence length between 7 and 12 words. + if in_sentence >= 7 + (n as u32 % 6) { + text.push_str(".\n"); + in_sentence = 0; + } else { + text.push(' '); + } + } + text +} + +fn dispatch(body: &[u8]) { + let outcome = compress_anthropic_live_zone(body, 0, AuthMode::Payg, DEFAULT_MODEL) + .expect("dispatcher returns Ok on valid bodies"); + black_box(outcome); +} + +fn bench_dispatch(c: &mut Criterion) { + let cases: &[(&str, Vec)] = &[ + ( + "source_code_2kb", + body_with_tool_result(&python_source(2_200)), + ), + ( + "source_code_20kb", + body_with_tool_result(&python_source(20_000)), + ), + ("plain_text_8kb", body_with_tool_result(&prose(8_192))), + ("below_threshold_prose", body_with_tool_result(&prose(400))), + ]; + + // One warm-up dispatch per payload BEFORE any timed group, so + // process-latched one-time costs (compressor singletons, the + // Kompress `OnceLock` init and — cache-warm — its model load) land + // here rather than skewing the first timed case. + for (_, body) in cases { + dispatch(body); + } + + let mut group = c.benchmark_group("live_zone/dispatch"); + // The Kompress arm runs a real ONNX inference per iteration when + // the model is cache-resident; keep sampling time bounded. + group.sample_size(30); + for (name, body) in cases { + group.throughput(Throughput::Bytes(body.len() as u64)); + group.bench_function(*name, |b| b.iter(|| dispatch(black_box(body)))); + } + group.finish(); +} + +/// Refuse to run on Windows without `ORT_DYLIB_PATH` — but only when the +/// hang it guards against is actually reachable. The `ort`-init deadlock +/// needs a warm Kompress model cache: cache-cold, `Kompress::from_cache` +/// resolves `Ok(None)` before any ONNX session exists, no `ort` code +/// runs, and the cache-cold measurement mode the module doc advertises +/// is perfectly safe — refusing it would block a legitimate +/// configuration to guard against a hang it cannot have. A hung +/// benchmark process gives no diagnostic; this message does. +#[cfg(windows)] +fn check_ort_dylib_path() { + match std::env::var("ORT_DYLIB_PATH") { + Ok(v) if !v.trim().is_empty() => {} + _ if !kompress_model_cached() => { + eprintln!( + "live_zone_dispatch bench: ORT_DYLIB_PATH is not set, but the \ + Kompress model cache is cold — no ONNX session will be \ + created, so the Windows ort-init hang cannot occur. \ + Proceeding in cache-cold mode (the plain_text case measures \ + the deterministic no-op path)." + ); + } + _ => { + eprintln!( + "live_zone_dispatch bench: ORT_DYLIB_PATH is not set.\n\n\ + On Windows with a warm Kompress model cache, ONNX Runtime's\n\ + shared-library resolution can deadlock during `ort` init (see\n\ + docs/content/docs/troubleshooting.mdx), which would hang the\n\ + plain_text bench case indefinitely. Set ORT_DYLIB_PATH to the\n\ + onnxruntime shared library and re-run, e.g.:\n\n \ + ORT_DYLIB_PATH=C:\\path\\to\\onnxruntime.dll cargo bench \ + -p headroom-core --bench live_zone_dispatch\n\n\ + Refusing to start rather than risk a silent hang." + ); + std::process::exit(2); + } + } +} + +/// Whether the Kompress model is cache-resident, asked of the loader +/// itself. A hand-rolled probe would have to mirror +/// `Kompress::from_cache`'s root and artifact resolution and would go +/// stale silently; this cannot. +#[cfg(all(windows, feature = "ml"))] +fn kompress_model_cached() -> bool { + use headroom_core::transforms::kompress::{Kompress, KompressConfig}; + + matches!(Kompress::from_cache(KompressConfig::default()), Ok(Some(_))) +} + +/// Without the `ml` feature there is no ONNX session to deadlock on. +#[cfg(all(windows, not(feature = "ml")))] +fn kompress_model_cached() -> bool { + false +} + +criterion_group!(benches, bench_dispatch); + +fn main() { + #[cfg(windows)] + check_ort_dylib_path(); + benches(); + Criterion::default().configure_from_args().final_summary(); +} diff --git a/crates/headroom-core/src/lib.rs b/crates/headroom-core/src/lib.rs index 76d4b4daf6..f209b4131a 100644 --- a/crates/headroom-core/src/lib.rs +++ b/crates/headroom-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod auth_mode; pub mod cache_control; pub mod ccr; pub mod compression_policy; +pub mod offline; #[cfg(feature = "ml")] mod onnx_cpu; pub mod relevance; @@ -29,6 +30,24 @@ pub fn hello() -> &'static str { "headroom-core" } +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::{Mutex, MutexGuard}; + + /// The process environment is global while `cargo test` runs tests in + /// parallel threads inside one process, so a test that sets an env var can + /// be observed by an unrelated sibling mid-assertion. Every test that + /// mutates the environment holds this for its whole body. Same shape as + /// `REGISTRY_LOCK` in `tokenizer::registry`. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Recovers from poisoning on purpose: one panicking test should not + /// cascade into every later test that touches the environment. + pub(crate) fn env_lock() -> MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/headroom-core/src/offline.rs b/crates/headroom-core/src/offline.rs new file mode 100644 index 0000000000..53412c9d79 --- /dev/null +++ b/crates/headroom-core/src/offline.rs @@ -0,0 +1,181 @@ +//! Air-gap / no-egress master switch (`HEADROOM_OFFLINE`) — Rust half. +//! +//! The Python side lives in `headroom/offline.py`. This is the same switch +//! read by the same environment variable with the same truthiness rules, so a +//! deployment that sets `HEADROOM_OFFLINE=1` gets identical behaviour whether +//! the egress attempt originates in the Python proxy or in a Rust core path. +//! +//! Two functions, mirroring the Python module deliberately: +//! +//! - [`is_offline`] — the predicate, for code that wants to take a different +//! route (use a cached artifact, skip an optional refresh). +//! - [`guard_egress`] — the chokepoint, for code that is about to open a +//! socket. It returns [`OfflineEgressBlocked`], a distinct error type, so a +//! caller that otherwise degrades gracefully on network failure can still +//! tell "the operator air-gapped this box" from "the network was flaky" and +//! refuse loudly instead of silently falling back. +//! +//! # Parity contract +//! +//! `HEADROOM_OFFLINE` is true for `1`, `true`, `yes`, `on` — trimmed and +//! ASCII-case-insensitive — and false for anything else, including unset and +//! empty. Any change here must be mirrored in `headroom/offline.py`'s +//! `_TRUE_VALUES`, and vice versa: the two implementations are a pair, and a +//! deployment that reads as offline to Python but online to Rust is precisely +//! the failure this module exists to prevent. +//! +//! The parity covers **normalisation**, not just the accepted values. This +//! used to call `str::trim()` while Python called `str.strip()`, and those are +//! not the same set: Python's strips U+001C-U+001F (the ASCII file/group/ +//! record/unit separators) because `str.isspace()` includes them, Rust's does +//! not because the Unicode `White_Space` property does not. `HEADROOM_OFFLINE` +//! set to `"\x1c1"` read as offline to Python and online to Rust — one +//! environment variable, one process air-gapped and the other not. Both sides +//! now trim exactly [`TRIM_CHARS`]. + +use std::env; + +use thiserror::Error; + +/// The environment variable that selects fully-offline operation. +pub const OFFLINE_ENV: &str = "HEADROOM_OFFLINE"; + +/// Values that read as "yes, offline". Kept byte-identical to the Python +/// side's `_TRUE_VALUES` (see the parity contract in the module docs). +const TRUE_VALUES: [&str; 4] = ["1", "true", "yes", "on"]; + +/// Whitespace trimmed off the raw value before matching. Enumerated rather +/// than left to `str::trim()`, which is the Unicode `White_Space` property and +/// does not agree with Python's `str.strip()`. Kept byte-identical to the +/// Python side's `_TRIM_CHARS` (see the parity contract in the module docs). +const TRIM_CHARS: [char; 6] = [' ', '\t', '\n', '\r', '\u{b}', '\u{c}']; + +/// An egress attempt was refused because `HEADROOM_OFFLINE` is in force. +/// +/// Carries the human-readable `purpose` and `destination` handed to +/// [`guard_egress`] so the operator learns which feature to turn off rather +/// than just that "something" was blocked. +#[derive(Debug, Clone, Error)] +#[error( + "{OFFLINE_ENV} is set: refusing outbound network access for {purpose} to \ + {destination}. Unset {OFFLINE_ENV}, or turn off the feature that needs \ + this connection." +)] +pub struct OfflineEgressBlocked { + pub purpose: String, + pub destination: String, +} + +/// Return `true` when `HEADROOM_OFFLINE` selects fully-offline operation. +pub fn is_offline() -> bool { + match env::var(OFFLINE_ENV) { + Ok(raw) => { + let normalized = raw.trim_matches(TRIM_CHARS.as_slice()).to_ascii_lowercase(); + TRUE_VALUES.contains(&normalized.as_str()) + } + // Unset, or not valid UTF-8 — neither is an opt-in to offline mode. + Err(_) => false, + } +} + +/// The chokepoint every Rust egress path must call before opening a socket. +/// +/// `Ok(())` when online; `Err(OfflineEgressBlocked)` when `HEADROOM_OFFLINE` +/// is set. Call it *before* constructing the HTTP client, not merely before +/// the request — several clients (`ureq`, `reqwest`) resolve DNS or warm a +/// connection pool during setup, so a guard placed at the request would leak +/// the very packets the air-gap switch promises not to send. +pub fn guard_egress(purpose: &str, destination: &str) -> Result<(), OfflineEgressBlocked> { + if is_offline() { + return Err(OfflineEgressBlocked { + purpose: purpose.to_string(), + destination: destination.to_string(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::env_lock; + + #[test] + fn unset_is_online() { + let _guard = env_lock(); + env::remove_var(OFFLINE_ENV); + assert!(!is_offline()); + assert!(guard_egress("anything", "https://example.invalid").is_ok()); + } + + #[test] + fn truthy_values_match_python() { + let _guard = env_lock(); + // Same set, same trimming, same case-insensitivity as + // headroom/offline.py::_TRUE_VALUES. If this list drifts, the two + // halves of the switch disagree and the air-gap has a hole. + for raw in ["1", "true", "TRUE", "Yes", " on ", "\ton\n"] { + env::set_var(OFFLINE_ENV, raw); + assert!(is_offline(), "{raw:?} should read as offline"); + } + env::remove_var(OFFLINE_ENV); + } + + #[test] + fn falsy_values_stay_online() { + let _guard = env_lock(); + for raw in ["", "0", "off", "no", "false", "maybe", " "] { + env::set_var(OFFLINE_ENV, raw); + assert!(!is_offline(), "{raw:?} should read as online"); + } + env::remove_var(OFFLINE_ENV); + } + + /// Normalisation parity, not just value parity. Every case here is + /// duplicated verbatim in `tests/test_offline_egress_chokepoint.py`'s + /// `test_normalisation_matches_the_rust_side`, and the two must agree. + /// + /// The `\x1c` cases are the regression: they are whitespace to Python's + /// `str.strip()` and not to Rust's `str::trim()`, so before `TRIM_CHARS` + /// existed `"\x1c1"` air-gapped the Python proxy and left the Rust core + /// dialling out. + #[test] + fn normalisation_matches_python() { + let _guard = env_lock(); + for (raw, expected) in [ + ("1", true), + (" 1 ", true), + ("\t1\n", true), + ("\r\n TRUE \r\n", true), + ("\u{b}yes\u{c}", true), + ("\u{1c}1", false), + ("1\u{1f}", false), + ("\u{a0}1", false), + ("\u{2007}on", false), + ("", false), + (" ", false), + ] { + env::set_var(OFFLINE_ENV, raw); + assert_eq!(is_offline(), expected, "{raw:?}"); + } + env::remove_var(OFFLINE_ENV); + } + + #[test] + fn guard_reports_purpose_and_destination() { + let _guard = env_lock(); + env::set_var(OFFLINE_ENV, "1"); + let err = guard_egress("HuggingFace tokenizer download", "hf.co/acme/model") + .expect_err("guard must refuse while offline"); + assert_eq!(err.purpose, "HuggingFace tokenizer download"); + assert_eq!(err.destination, "hf.co/acme/model"); + let rendered = err.to_string(); + assert!(rendered.contains(OFFLINE_ENV), "{rendered}"); + assert!( + rendered.contains("HuggingFace tokenizer download"), + "{rendered}" + ); + assert!(rendered.contains("hf.co/acme/model"), "{rendered}"); + env::remove_var(OFFLINE_ENV); + } +} diff --git a/crates/headroom-core/src/relevance/embedding.rs b/crates/headroom-core/src/relevance/embedding.rs index 02facf02b9..96f3fb4471 100644 --- a/crates/headroom-core/src/relevance/embedding.rs +++ b/crates/headroom-core/src/relevance/embedding.rs @@ -98,6 +98,22 @@ impl EmbeddingScorer { /// quality/speed tradeoff for compression-relevance scoring on /// short snippets. pub fn try_new_with_model(model_kind: EmbeddingModel) -> Result { + let name = format!("{:?}", model_kind); + // Air-gap chokepoint, FIRST thing in the function. `TextEmbedding:: + // try_new` resolves the model's ONNX weights through `hf-hub`, which + // downloads from huggingface.co on a cache miss even with + // `HF_HUB_OFFLINE` set — the same hole the tokenizer and Kompress model + // fetches had. It sits ahead of the AVX2 and ort-loader probes below so + // that an air-gapped box reports the policy refusal rather than + // whichever local prerequisite happened to be missing as well. + // + // The refusal is soft here by design: this function already returns + // `Err(String)` for "ONNX runtime unavailable" and every caller degrades + // to the BM25 scorer, so an air-gapped box loses embedding relevance + // instead of failing a request. The message names the switch so the + // operator can tell the two causes apart in the log. + crate::offline::guard_egress("fastembed embedding model download", &name) + .map_err(|e| format!("EmbeddingScorer model load refused: {e}"))?; // fastembed links the precompiled ONNX Runtime binary, which contains // AVX2 instructions on x86. Loading/running it on a non-AVX2 CPU traps // with SIGILL (issue #1723) — an uncatchable native fault. Bail early so @@ -113,7 +129,6 @@ impl EmbeddingScorer { // `dynamic_ort_loader_ready`). crate::transforms::magika_detector::dynamic_ort_loader_ready() .map_err(|e| format!("EmbeddingScorer: ONNX Runtime unavailable: {e}"))?; - let name = format!("{:?}", model_kind); let model = TextEmbedding::try_new(InitOptions::new(model_kind)) .map_err(|e| format!("EmbeddingScorer model load failed: {}", e))?; Ok(EmbeddingScorer { @@ -308,6 +323,28 @@ mod tests { std::env::var("RUN_FASTEMBED_TESTS").is_ok() } + /// `try_new_with_model` must refuse before fastembed resolves the model, + /// and must say so in the returned message rather than looking like an + /// ordinary load failure — the caller only ever sees the `String`. + /// + /// Not gated on `RUN_FASTEMBED_TESTS`: the whole point is that no network + /// call happens, so this is safe to run in CI. If the guard were removed + /// this test would either download ~30 MB or fail with a load error, and + /// neither says "refused". + #[cfg(feature = "ml")] + #[test] + fn try_new_refuses_while_offline() { + let _guard = crate::test_support::env_lock(); + std::env::set_var(crate::offline::OFFLINE_ENV, "1"); + let result = EmbeddingScorer::try_new(); + std::env::remove_var(crate::offline::OFFLINE_ENV); + + let err = result.err().expect("guard must refuse while offline"); + assert!(err.contains("refused"), "{err}"); + assert!(err.contains(crate::offline::OFFLINE_ENV), "{err}"); + assert!(err.contains("fastembed embedding model download"), "{err}"); + } + /// Construct a stub scorer with `model = None` for offline-safe /// tests of the unavailable-path behavior. #[cfg(feature = "ml")] diff --git a/crates/headroom-core/src/tokenizer/hf_impl.rs b/crates/headroom-core/src/tokenizer/hf_impl.rs index a63d9f94dc..f120c76ac2 100644 --- a/crates/headroom-core/src/tokenizer/hf_impl.rs +++ b/crates/headroom-core/src/tokenizer/hf_impl.rs @@ -49,6 +49,13 @@ pub enum HfTokenizerError { #[source] source: Box, }, + /// `HEADROOM_OFFLINE` is set, so the Hub fetch was refused before any + /// socket was opened. Deliberately a separate variant from [`Self::Hub`]: + /// a caller that retries or falls back on a transient download failure + /// must not do either here — the operator asked for no egress, and + /// retrying an air-gap refusal is just a slower refusal. + #[error(transparent)] + Offline(#[from] crate::offline::OfflineEgressBlocked), } /// Token counter backed by a HuggingFace `tokenizer.json`. @@ -110,11 +117,35 @@ impl HfTokenizer { /// processes hit the on-disk cache. /// /// Errors: + /// - [`HfTokenizerError::Offline`] when `HEADROOM_OFFLINE` is set. Refused + /// before any socket is opened; see below. /// - [`HfTokenizerError::Hub`] for download failures (no network, 404, /// 401 on a gated model without `HF_TOKEN`). /// - [`HfTokenizerError::Load`] if the downloaded bytes don't parse as /// a valid `tokenizer.json`. Should not happen for healthy HF repos. pub fn from_pretrained(repo: &str) -> Result { + // Air-gap chokepoint, matching `headroom/offline.py`'s `guard_egress`. + // This is the Rust half of the same switch, so an operator who sets + // HEADROOM_OFFLINE=1 gets the same refusal whichever runtime reaches + // for the Hub. + // + // It has to sit here rather than around the `get` below: `Api::new` + // builds the `ureq` agent and resolves the endpoint, and hf-hub's own + // HF_HUB_OFFLINE handling only covers the cache lookup — a cache miss + // still dials out. Guarding first means the air-gapped case never + // constructs a client at all. + // + // The refusal is hard on purpose. Silently returning a cached or + // estimating tokenizer would leave token counts subtly wrong with no + // signal; the caller (`tokenizer::registry::try_register_hf`) already + // propagates the error so the operator sees which repo was wanted. + // + // Note it refuses even on a warm cache: `hf-hub` 0.5 exposes no + // cache-hit/cache-miss split at this layer, so "guard before the + // client exists" and "serve from the cache" cannot both hold here. An + // air-gapped deployment with pre-seeded artifacts should point at them + // with `from_file`, which is network-free by construction. + crate::offline::guard_egress("HuggingFace tokenizer download", repo)?; let api = hf_hub::api::sync::Api::new().map_err(|e| HfTokenizerError::Hub { repo: repo.to_string(), source: Box::new(e), @@ -260,6 +291,41 @@ mod tests { assert!(Arc::ptr_eq(&a.inner, &b.inner)); } + /// A-2: `HEADROOM_OFFLINE=1` must stop the Hub fetch before a socket exists. + /// + /// The repo name is deliberately one that cannot exist, which is what makes + /// this a real assertion rather than a tautology: without the guard the call + /// reaches `hf-hub`, dials `huggingface.co`, gets a 401/404 and returns + /// `HfTokenizerError::Hub`. Only the guard can produce `Offline`, and only + /// by returning before `Api::new` — so `Offline` *is* the "no socket was + /// opened" assertion for this path. (A packet-level assertion is not + /// available to a `cargo test` unit test the way `socket.socket` patching is + /// in Python; this typed-error distinction is the equivalent signal.) + #[test] + fn from_pretrained_refuses_while_offline() { + let _guard = crate::test_support::env_lock(); + std::env::set_var(crate::offline::OFFLINE_ENV, "1"); + let result = HfTokenizer::from_pretrained("headroomlabs-ai/this-repo-does-not-exist-a2"); + std::env::remove_var(crate::offline::OFFLINE_ENV); + + match result { + Err(HfTokenizerError::Offline(blocked)) => { + assert_eq!(blocked.purpose, "HuggingFace tokenizer download"); + assert_eq!( + blocked.destination, + "headroomlabs-ai/this-repo-does-not-exist-a2" + ); + } + Err(other) => panic!("expected an offline refusal, got a network error: {other}"), + Ok(_) => panic!("expected an offline refusal, got a tokenizer"), + } + } + + // The "switch off ⇒ guard is a no-op" half is covered in `crate::offline`'s + // own tests rather than here: asserting it through `from_pretrained` would + // mean actually dialling huggingface.co, which is exactly the thing a unit + // test must not do. + #[test] fn from_file_loads_a_real_file() { // Round-trip via a temp file to cover the on-disk constructor. diff --git a/crates/headroom-core/src/transforms/content_detector.rs b/crates/headroom-core/src/transforms/content_detector.rs index 1314b3fd0a..1c6cd7c1e6 100644 --- a/crates/headroom-core/src/transforms/content_detector.rs +++ b/crates/headroom-core/src/transforms/content_detector.rs @@ -9,6 +9,7 @@ //! - **SearchResults**: grep / ripgrep output (`file:line:content`) //! - **BuildOutput**: Compiler / test / lint logs //! - **GitDiff**: Unified diff format → `DiffCompressor` +//! - **Tabular**: lossless route for tables and human-readable Git status //! - **Html**: Web pages (needs extraction, not compression) //! - **PlainText**: Generic fallback //! @@ -37,10 +38,31 @@ pub enum ContentType { BuildOutput, GitDiff, Html, + Tabular, PlainText, } impl ContentType { + /// Every variant, in declaration order. + /// + /// Rust has no stable reflection over enum variants, so callers that + /// need to enumerate the set (operator-facing name tables, exhaustive + /// round-trip tests) would otherwise hand-maintain their own copy and + /// silently miss a variant added later. Adding a variant without + /// extending this array is caught by the length annotation, and the + /// exhaustive `match` in [`ContentType::as_str`] forces the author + /// into this file in the first place. + pub const ALL: [ContentType; 8] = [ + ContentType::JsonArray, + ContentType::SourceCode, + ContentType::SearchResults, + ContentType::BuildOutput, + ContentType::GitDiff, + ContentType::Html, + ContentType::Tabular, + ContentType::PlainText, + ]; + /// Stable string tag — matches Python's `ContentType..value`. pub fn as_str(&self) -> &'static str { match self { @@ -50,9 +72,51 @@ impl ContentType { ContentType::BuildOutput => "build", ContentType::GitDiff => "diff", ContentType::Html => "html", + ContentType::Tabular => "tabular", ContentType::PlainText => "text", } } + + /// Operator-facing spelling of this variant: the name a human writes + /// in configuration. Equal to [`ContentType::as_str`] except where + /// that tag is abbreviated for Python parity (`search`, `build`, + /// `diff`, `text`), which are the spellings least likely to be + /// guessed correctly. Both forms parse — see the `FromStr` impl. + pub fn natural_name(&self) -> &'static str { + match self { + ContentType::JsonArray => "json_array", + ContentType::SourceCode => "source_code", + ContentType::SearchResults => "search_results", + ContentType::BuildOutput => "build_output", + ContentType::GitDiff => "git_diff", + ContentType::Html => "html", + ContentType::Tabular => "tabular", + ContentType::PlainText => "plain_text", + } + } +} + +/// Error returned when a string names no [`ContentType`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("unknown content type {0:?}")] +pub struct ParseContentTypeError(String); + +/// Parse a [`ContentType`] from either its [`as_str`](ContentType::as_str) +/// tag or its [`natural_name`](ContentType::natural_name). +/// +/// Accepting both matters for operator-facing configuration: the +/// abbreviated Python-parity tags (`text`, `search`, `build`, `diff`) are +/// not what a human writes, so a config naming `plain_text` would +/// otherwise parse as nothing and silently do nothing. +impl std::str::FromStr for ContentType { + type Err = ParseContentTypeError; + + fn from_str(name: &str) -> Result { + Self::ALL + .into_iter() + .find(|ct| ct.as_str() == name || ct.natural_name() == name) + .ok_or_else(|| ParseContentTypeError(name.to_owned())) + } } /// Result of `detect_content_type`. `metadata` is per-type free-form key/ @@ -228,6 +292,93 @@ static HTML_STRUCTURAL_TAGS: LazyLock = LazyLock::new(|| { .unwrap() }); +#[derive(Clone, Copy, PartialEq, Eq)] +enum GitStatusSection { + Staged, + Unstaged, + Untracked, + Unmerged, +} + +fn try_detect_git_status(content: &str) -> Option { + let mut lines = content.lines(); + let first = lines.next()?; + if !first.starts_with("On branch ") + && !first.starts_with("HEAD detached at ") + && !first.starts_with("HEAD detached from ") + { + return None; + } + + let mut has_heading = false; + let mut has_path = false; + let mut section = None; + for line in lines { + section = match line { + "Changes to be committed:" => { + has_heading = true; + Some(GitStatusSection::Staged) + } + "Changes not staged for commit:" => { + has_heading = true; + Some(GitStatusSection::Unstaged) + } + "Untracked files:" => { + has_heading = true; + Some(GitStatusSection::Untracked) + } + "Unmerged paths:" => { + has_heading = true; + Some(GitStatusSection::Unmerged) + } + _ => section, + }; + + if section == Some(GitStatusSection::Untracked) + && line + .strip_prefix('\t') + .is_some_and(|path| !path.trim().is_empty()) + { + has_path = true; + } else if matches!( + section, + Some( + GitStatusSection::Staged | GitStatusSection::Unstaged | GitStatusSection::Unmerged + ) + ) { + let Some((state, path)) = line.trim_start().split_once(": ") else { + continue; + }; + if !path.trim().is_empty() + && matches!( + state, + "new file" + | "modified" + | "deleted" + | "renamed" + | "copied" + | "typechange" + | "both modified" + | "both added" + | "both deleted" + | "added by us" + | "deleted by us" + | "added by them" + | "deleted by them" + ) + { + has_path = true; + } + } + } + + (has_heading && has_path).then(|| { + let mut metadata = Map::new(); + metadata.insert("format".into(), Value::String("git_status".into())); + DetectionResult::new(ContentType::Tabular, 1.0, metadata) + }) +} + // ─── Public entry point ──────────────────────────────────────────────── /// Detect the type of `content` for routing. Mirrors Python's @@ -238,10 +389,11 @@ static HTML_STRUCTURAL_TAGS: LazyLock = LazyLock::new(|| { /// 2. JSON array (highest priority for `SmartCrusher`) /// 3. Git diff (≥ 0.7 confidence required) /// 4. HTML (≥ 0.7 confidence required) -/// 5. Search results (≥ 0.6 confidence required) -/// 6. Build / log output (≥ 0.5 confidence required) -/// 7. Source code (≥ 0.5 confidence required) -/// 8. Fallback to `PlainText` confidence 0.5 +/// 5. Human-readable Git status → `Tabular` (lossless) +/// 6. Search results (≥ 0.6 confidence required) +/// 7. Build / log output (≥ 0.5 confidence required) +/// 8. Source code (≥ 0.5 confidence required) +/// 9. Fallback to `PlainText` confidence 0.5 pub fn detect_content_type(content: &str) -> DetectionResult { if content.is_empty() || content.trim().is_empty() { return DetectionResult::plain_text(0.0); @@ -260,6 +412,10 @@ pub fn detect_content_type(content: &str) -> DetectionResult { return r; } } + if let Some(r) = try_detect_git_status(content) { + return r; + } + if let Some(r) = try_detect_search(content) { if r.confidence >= 0.6 { return r; @@ -621,6 +777,57 @@ mod tests { assert!(r.confidence >= 0.6); } + #[test] + fn git_status_with_staged_unstaged_and_untracked_paths_is_tabular() { + let content = "\ +On branch feature/status +Changes to be committed: + (use \"git restore --staged ...\" to unstage) +\tnew file: staged file.txt + +Changes not staged for commit: + (use \"git add ...\" to update what will be committed) +\tmodified: path with spaces.txt + +Untracked files: + (use \"git add ...\" to include in what will be committed) +\tuntracked path.txt +"; + let result = detect_content_type(content); + assert_eq!(result.content_type, ContentType::Tabular); + assert_eq!( + result.metadata.get("format").and_then(Value::as_str), + Some("git_status") + ); + } + + #[test] + fn untracked_only_git_status_is_tabular() { + let content = "\ +On branch feature/status + +Untracked files: + (use \"git add ...\" to include in what will be committed) +\tuntracked path with spaces.txt +\tuntracked α.rs +"; + let result = detect_content_type(content); + assert_eq!(result.content_type, ContentType::Tabular); + assert_eq!( + result.metadata.get("format").and_then(Value::as_str), + Some("git_status") + ); + } + + #[test] + fn ordinary_bullet_list_is_not_git_status() { + let prose = "The branch has many changes:\n- modified: item one\n- modified: item two"; + assert_eq!( + detect_content_type(prose).content_type, + ContentType::PlainText + ); + } + #[test] fn git_diff_detected() { let content = "\ diff --git a/crates/headroom-core/src/transforms/kompress.rs b/crates/headroom-core/src/transforms/kompress.rs index 455c179dd0..06c51b0751 100644 --- a/crates/headroom-core/src/transforms/kompress.rs +++ b/crates/headroom-core/src/transforms/kompress.rs @@ -180,6 +180,12 @@ pub enum KompressError { #[source] source: Box, }, + /// `HEADROOM_OFFLINE` is set, so the Hub fetch was refused before any + /// socket was opened. A separate variant from [`Self::Hub`] for the same + /// reason as [`crate::tokenizer::HfTokenizerError::Offline`]: retrying or + /// falling back on a policy refusal is just a slower refusal. + #[error(transparent)] + Offline(#[from] crate::offline::OfflineEgressBlocked), } // ─── Compressor ───────────────────────────────────────────────────────── @@ -255,6 +261,22 @@ impl Kompress { /// downloading on miss). Blocking — call off the hot path. Tries the /// [`ONNX_CANDIDATES`] in order. pub fn from_pretrained(config: KompressConfig) -> Result { + // Air-gap chokepoint — the same one `hf_impl.rs` uses for the + // tokenizer, for the same reason. `HF_HUB_OFFLINE` only shortcuts the + // cache *lookup*; on a cache miss `hf-hub` still dials huggingface.co, + // so the Kompress model download is a live egress path under + // `HEADROOM_OFFLINE=1` and was not covered. + // + // It sits before `Api::new()` because that already resolves the Hub + // endpoint and builds the `ureq` agent, so guarding at the `get` call + // would leak the setup packets the switch promises not to send. + // + // Trade-off, stated plainly: this refuses even when the artifacts are + // already in `~/.cache/huggingface/hub`, because `hf-hub` 0.5 gives no + // cache-hit/cache-miss split at this layer. An air-gapped caller that + // has pre-seeded artifacts should use [`Self::from_files`], which is + // network-free by construction and unaffected by this guard. + crate::offline::guard_egress("Kompress model download", &config.model_id)?; let api = hf_hub::api::sync::Api::new().map_err(|e| KompressError::Hub { repo: config.model_id.clone(), source: Box::new(e), @@ -635,6 +657,38 @@ fn hf_hub_roots() -> Vec { mod tests { use super::*; + /// `from_pretrained` must refuse before `Api::new()` when the operator + /// air-gapped the box. + /// + /// The model id cannot exist, which is what makes this a real assertion + /// rather than a tautology: without the guard the call reaches `hf-hub`, + /// dials `huggingface.co` and comes back `KompressError::Hub`. Only the + /// guard can produce `Offline`, and only by returning before the client is + /// built — so `Offline` *is* the "no socket was opened" assertion here. + #[test] + fn from_pretrained_refuses_while_offline() { + let _guard = crate::test_support::env_lock(); + std::env::set_var(crate::offline::OFFLINE_ENV, "1"); + let config = KompressConfig { + model_id: "headroomlabs-ai/this-model-does-not-exist-a2".to_string(), + ..KompressConfig::default() + }; + let result = Kompress::from_pretrained(config); + std::env::remove_var(crate::offline::OFFLINE_ENV); + + match result { + Err(KompressError::Offline(blocked)) => { + assert_eq!(blocked.purpose, "Kompress model download"); + assert_eq!( + blocked.destination, + "headroomlabs-ai/this-model-does-not-exist-a2" + ); + } + Err(other) => panic!("expected an offline refusal, got a network error: {other}"), + Ok(_) => panic!("expected an offline refusal, got a loaded model"), + } + } + #[test] fn config_defaults_match_kompress_v2_base() { let c = KompressConfig::default(); diff --git a/crates/headroom-core/src/transforms/live_zone.rs b/crates/headroom-core/src/transforms/live_zone.rs index 6105dd3462..a41fee3d22 100644 --- a/crates/headroom-core/src/transforms/live_zone.rs +++ b/crates/headroom-core/src/transforms/live_zone.rs @@ -49,15 +49,20 @@ //! //! - **PR-B2** shipped the dispatcher *skeleton*: identify live-zone //! blocks, route to no-op compressors, always return `NoChange`. -//! - **PR-B3** (this PR) wires per-content-type compressors: +//! - **PR-B3** wired the first per-content-type compressors: //! `JsonArray` → SmartCrusher; `BuildOutput` → LogCompressor; -//! `SearchResults` → SearchCompressor; `GitDiff` → DiffCompressor; -//! `SourceCode` / `PlainText` / `Html` → no-op (B4 + a Rust -//! code-compressor port follow-up). -//! - **PR-B4** adds the tokenizer-validation gate (per-block +//! `SearchResults` → SearchCompressor; `GitDiff` → DiffCompressor — +//! leaving `SourceCode` / `PlainText` / `Html` as documented no-ops. +//! - **PR-B4** specifies the tokenizer-validation gate (per-block //! `compressed.tokens >= original.tokens` → fall back) and the -//! per-content-type byte threshold below which compression is -//! skipped. +//! per-content-type byte thresholds. The gate and the threshold +//! plumbing already shipped; **this PR** completes the item by wiring +//! the two remaining compressor arms — `SourceCode` → +//! CodeAwareCompressor (falling back to cached Kompress when the +//! compressor returns its input unchanged) and `PlainText` → cached +//! Kompress — and by moving the `SourceCode` / `PlainText` thresholds +//! off their placeholder 512 to the specified 2048 / 5120. `Html` +//! stays no-op. //! - **PR-B7** wires CCR retrieval-marker injection. //! //! # Cache safety invariant @@ -96,13 +101,19 @@ use std::{collections::HashSet, sync::OnceLock}; +#[cfg(feature = "ml")] +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; + use serde::Deserialize; use serde_json::value::RawValue; use serde_json::Value; use thiserror::Error; +use super::code_compressor::{CodeAwareCompressor, CodeCompressorConfig}; use super::content_detector::{detect_content_type, ContentType}; use super::diff_compressor::{DiffCompressor, DiffCompressorConfig}; +#[cfg(feature = "ml")] +use super::kompress::{Kompress, KompressConfig}; use super::log_compressor::{LogCompressor, LogCompressorConfig}; use super::search_compressor::{SearchCompressor, SearchCompressorConfig}; use super::smart_crusher::{SmartCrusher, SmartCrusherConfig}; @@ -119,6 +130,14 @@ const STRATEGY_LOG_COMPRESSOR: &str = "log_compressor"; const STRATEGY_SEARCH_COMPRESSOR: &str = "search_compressor"; /// Strategy tag emitted when DiffCompressor rewrote a unified-diff block. const STRATEGY_DIFF_COMPRESSOR: &str = "diff_compressor"; +/// Strategy tag emitted when CodeAwareCompressor rewrote a source-code block. +const STRATEGY_CODE_COMPRESSOR: &str = "code_compressor"; +/// Strategy tag emitted when Kompress rewrote a plain-text block (also +/// used by the SourceCode arm's passthrough fallback — see +/// [`dispatch_compressor`]). Only the `ml` build has a Kompress arm to +/// emit it. +#[cfg(feature = "ml")] +const STRATEGY_KOMPRESS: &str = "kompress"; /// Empty query context passed to compressors that take a relevance /// query string. PR-B3 dispatcher does not yet plumb the user's last @@ -158,22 +177,36 @@ const THRESHOLD_BUILD_OUTPUT: usize = 512; const THRESHOLD_SEARCH_RESULTS: usize = 512; /// Git-diff blocks below this size route to no-op. const THRESHOLD_GIT_DIFF: usize = 512; -/// Source-code blocks below this size route to no-op. Pinned -/// for the future Rust code-compressor port — currently unused -/// because `ContentType::SourceCode` short-circuits to no-op above -/// the dispatch (see `dispatch_compressor`). -const THRESHOLD_SOURCE_CODE: usize = 512; -/// Plain-text blocks below this size route to no-op. Pinned -/// for the future Kompress wiring (PR-B7 follow-up); currently unused. -const THRESHOLD_PLAIN_TEXT: usize = 512; -/// HTML blocks have no compressor; threshold matches plain text so -/// when an HTML compressor lands the value is already pinned. +/// Source-code blocks below this size route to no-op. Sourced from +/// `REALIGNMENT/04-phase-B-live-zone.md::PR-B4` (2 KiB) — the +/// CodeAwareCompressor's own `min_tokens_for_compression` floor +/// already guards tiny snippets, but the byte gate keeps the dispatcher +/// from spinning up tree-sitter parsing for content that can't possibly +/// clear it. +const THRESHOLD_SOURCE_CODE: usize = 2048; +/// Plain-text blocks below this size route to no-op. Sourced from +/// `REALIGNMENT/04-phase-B-live-zone.md::PR-B4` (5 KiB). The threshold +/// gate fires before dispatch, so sub-threshold prose never reaches the +/// Kompress arm wired below. +const THRESHOLD_PLAIN_TEXT: usize = 5120; +/// Tabular text has no native compressor; keep it lossless. +const THRESHOLD_TABULAR: usize = 512; + +/// HTML blocks have no compressor. 512 was PlainText's pre-PR-B4 value; +/// HTML deliberately keeps it rather than following PlainText to 5120, +/// because `REALIGNMENT/04` pins no HTML threshold — when an HTML +/// compressor lands, its threshold is that PR's own decision. const THRESHOLD_HTML: usize = 512; /// Map a content type to its byte threshold. Returning `usize` rather /// than an `Option` because every variant has a sensible default; /// `Html` is a no-op anyway so the threshold check never fires. -fn threshold_for(content_type: ContentType) -> usize { +/// +/// `pub` so tests and benches can ask "would this block have cleared the +/// gate?" against the real constants instead of mirroring them — a +/// mirrored copy drifts silently the moment a threshold moves. +#[must_use] +pub const fn threshold_for(content_type: ContentType) -> usize { match content_type { ContentType::JsonArray => THRESHOLD_JSON_ARRAY, ContentType::BuildOutput => THRESHOLD_BUILD_OUTPUT, @@ -182,6 +215,7 @@ fn threshold_for(content_type: ContentType) -> usize { ContentType::SourceCode => THRESHOLD_SOURCE_CODE, ContentType::PlainText => THRESHOLD_PLAIN_TEXT, ContentType::Html => THRESHOLD_HTML, + ContentType::Tabular => THRESHOLD_TABULAR, } } @@ -549,6 +583,274 @@ fn diff_compressor() -> &'static DiffCompressor { INSTANCE.get_or_init(|| DiffCompressor::new(DiffCompressorConfig::default())) } +fn code_compressor() -> &'static CodeAwareCompressor { + static INSTANCE: OnceLock = OnceLock::new(); + INSTANCE.get_or_init(|| CodeAwareCompressor::new(CodeCompressorConfig::default())) +} + +// ─── Kompress model slot: never initialized on the request path ──────── +// +// `Kompress::from_cache` is cache-only — it never downloads — but it is +// NOT cheap: it loads the tokenizer and commits a ~261 MB ONNX session, +// whole seconds of wall time on CPU, and on Windows without +// `ORT_DYLIB_PATH` the ONNX Runtime init can block indefinitely. A +// request thread must never pay, or wait on, that construction: the +// first qualifying dispatch CASes UNINIT → INITIALIZING, hands the build +// to a background thread, and returns a NoOp; every dispatch during +// INITIALIZING is a NoOp; READY is terminal and serves the slot — or a +// deterministic NoOp forever when the cache was cold or the load failed, +// which is never retried. `warm_live_zone_compressors` runs the same +// construction synchronously off the request path (proxy startup, +// tests), so steady-state traffic normally never meets the lazy path. + +#[cfg(feature = "ml")] +mod kompress_slot { + pub const UNINIT: u8 = 0; + pub const INITIALIZING: u8 = 1; + pub const READY: u8 = 2; +} + +#[cfg(feature = "ml")] +static KOMPRESS_STATE: AtomicU8 = AtomicU8::new(kompress_slot::UNINIT); + +/// Written exactly once, by whichever initializer won the CAS; the +/// `Release` store of `READY` publishes it to `Acquire` readers. +#[cfg(feature = "ml")] +static KOMPRESS_INSTANCE: OnceLock> = OnceLock::new(); + +/// How many times the expensive construction has actually run in this +/// process. Exactly-once observability; asserted by +/// `tests/live_zone_kompress_async_init.rs`. +#[cfg(feature = "ml")] +static KOMPRESS_INIT_RUNS: AtomicUsize = AtomicUsize::new(0); + +#[doc(hidden)] +#[cfg(feature = "ml")] +pub fn kompress_init_runs() -> usize { + KOMPRESS_INIT_RUNS.load(Ordering::Relaxed) +} + +/// Without the `ml` feature no construction can ever run. +#[doc(hidden)] +#[cfg(not(feature = "ml"))] +pub fn kompress_init_runs() -> usize { + 0 +} + +/// The one construction path. `Ok(None)` on a cold cache and hard errors +/// both settle the slot as `None` — a deterministic NoOp for the life of +/// the process, never retried — and the outcome is observable via the +/// `kompress_init_complete` event's `kompress_ready` field. +#[cfg(feature = "ml")] +fn run_kompress_init() { + KOMPRESS_INIT_RUNS.fetch_add(1, Ordering::Relaxed); + let slot = match Kompress::from_cache(KompressConfig::default()) { + Ok(k) => k, // None => HF cache cold: deterministic NoOp downstream + Err(e) => { + tracing::warn!(event = "kompress_init_failed", error = %e); + None + } + }; + let ready = slot.is_some(); + let _ = KOMPRESS_INSTANCE.set(slot); + KOMPRESS_STATE.store(kompress_slot::READY, Ordering::Release); + tracing::info!(event = "kompress_init_complete", kompress_ready = ready); +} + +/// Non-blocking request-path accessor for the Kompress slot. +/// +/// Returns the model only when a completed initialization loaded it; +/// otherwise `None`, so the caller passes text through untouched. This +/// mirrors the Python router's not-ready → passthrough behavior, and it +/// NEVER waits: a virgin slot starts the build on a background thread +/// and answers `None` for this request. +#[cfg(feature = "ml")] +fn kompress_cached() -> Option<&'static Kompress> { + match KOMPRESS_STATE.load(Ordering::Acquire) { + kompress_slot::READY => KOMPRESS_INSTANCE.get().and_then(|slot| slot.as_ref()), + kompress_slot::UNINIT => { + if KOMPRESS_STATE + .compare_exchange( + kompress_slot::UNINIT, + kompress_slot::INITIALIZING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + if let Err(e) = std::thread::Builder::new() + .name("kompress-init".into()) + .spawn(run_kompress_init) + { + // The expensive construction never started; give the + // (cheap) spawn another chance on a later dispatch. + KOMPRESS_STATE.store(kompress_slot::UNINIT, Ordering::Release); + tracing::warn!(event = "kompress_init_spawn_failed", error = %e); + } + } + None + } + // INITIALIZING (or an impossible value): fail open, never wait. + _ => None, + } +} + +/// Route `text` through Kompress when the model is cache-resident; +/// otherwise fall back to a deterministic no-op. Shared by the +/// `PlainText` arm and the `SourceCode` arm's passthrough fallback. +/// +/// Not a full port of the Python router's fallback chain: that one also +/// re-runs Kompress when the code compressor returns *changed but not +/// smaller* output, whereas this arm sends only an exact passthrough +/// here and leaves the changed-but-not-smaller case to the +/// tokenizer-rejection gate in `compress_one_block`. +#[cfg(feature = "ml")] +fn kompress_or_noop(text: &str, content_type: ContentType) -> DispatchResult { + if let Some(k) = kompress_cached() { + let result = k.compress(text); + if !result.is_passthrough() && result.compressed != text { + return DispatchResult::Compressed { + strategy: STRATEGY_KOMPRESS, + compressed: result.compressed, + }; + } + } + DispatchResult::NoOp { + content_type: content_type.as_str(), + } +} + +/// Kompress is an `ml`-feature transform; without it the arm is a +/// deterministic no-op. Separate definition rather than a lint-silenced +/// unused parameter, so the no-ml build has no dead argument to explain. +#[cfg(not(feature = "ml"))] +fn kompress_or_noop(_text: &str, content_type: ContentType) -> DispatchResult { + DispatchResult::NoOp { + content_type: content_type.as_str(), + } +} + +/// Eagerly construct every live-zone compressor singleton, off the +/// request path, and block until the Kompress slot has settled. Returns +/// whether Kompress is loaded and ready to compress — `false` on a cold +/// cache, a failed load, or a build without the `ml` feature. +/// +/// Call it at process startup (the proxy does, from a background thread) +/// or from tests that need the arm live before dispatching; the request +/// path itself never blocks either way. Deliberately ignores +/// `HEADROOM_LIVE_ZONE_DISABLE_ARMS`: the kill switch gates dispatch, +/// not model residency, so both switch configurations run the identical +/// warmed process and differ only in the dispatch decision. +pub fn warm_live_zone_compressors() -> bool { + let _ = smart_crusher(); + let _ = log_compressor(); + let _ = search_compressor(); + let _ = diff_compressor(); + let _ = code_compressor(); + warm_kompress_blocking() +} + +/// Blocking Kompress warmup: run the construction inline if this thread +/// wins the slot, otherwise wait for whichever initializer did. A poll +/// loop rather than `OnceLock::wait`, deliberately: a request thread +/// that won the CAS but failed to spawn its init thread resets the +/// state to UNINIT without ever setting the instance, and a waiter +/// parked on the `OnceLock` would sleep forever through that reset. +#[cfg(feature = "ml")] +fn warm_kompress_blocking() -> bool { + loop { + match KOMPRESS_STATE.load(Ordering::Acquire) { + kompress_slot::READY => { + return KOMPRESS_INSTANCE.get().is_some_and(|slot| slot.is_some()); + } + kompress_slot::UNINIT => { + if KOMPRESS_STATE + .compare_exchange( + kompress_slot::UNINIT, + kompress_slot::INITIALIZING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + run_kompress_init(); + } + // Lost the race to another initializer: re-read the state. + } + _ => std::thread::sleep(std::time::Duration::from_millis(10)), + } + } +} + +/// Without the `ml` feature there is no Kompress slot to warm. +#[cfg(not(feature = "ml"))] +fn warm_kompress_blocking() -> bool { + false +} + +// ─── Kill switch (env-var arm disable) ───────────────────────────────── +// +// Experiment control and rollback affordance: set +// `HEADROOM_LIVE_ZONE_DISABLE_ARMS=source_code,plain_text` (a +// comma-separated list of content-type names, in either the +// `ContentType::as_str()` or `natural_name()` spelling) to force those +// arms to a deterministic no-op, bypassing their compressor entirely. +// +// One operator-facing caveat: granularity is per ARM, not per +// compressor. `plain_text` alone does not stop Kompress runs reached +// through the SourceCode arm's passthrough fallback; disabling +// `source_code` as well is what removes Kompress from traffic entirely +// (at the cost of also disabling the code compressor). + +/// Parse the comma-separated arm list. Blank entries are skipped; +/// unknown names are ignored with a warning rather than failing the +/// request, because a typo in an operator's rollback switch must not +/// take the proxy down. +/// +/// Pure and `pub` so the parsing contract can be tested directly. The +/// alternative — asserting on it through the process environment — +/// requires `std::env::set_var`, which is unsound in a multi-threaded +/// process and becomes `unsafe` in edition 2024. +#[must_use] +pub fn parse_disabled_arms(raw: &str) -> HashSet { + let mut set = HashSet::new(); + for token in raw.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + match token.parse::() { + Ok(ct) => { + set.insert(ct); + } + Err(_) => tracing::warn!(event = "disable_arms_unknown_token", token = %token), + } + } + set +} + +/// Arms disabled via `HEADROOM_LIVE_ZONE_DISABLE_ARMS`. +/// +/// Latched on **first dispatch**, not at process start: the value is read +/// the first time a block is routed and cached for the process lifetime, +/// so a run's arm-disable set cannot change mid-flight (determinism +/// invariant). A non-UTF-8 value is read lossily rather than silently +/// treated as unset. +fn disabled_arms() -> &'static HashSet { + static INSTANCE: OnceLock> = OnceLock::new(); + INSTANCE.get_or_init(|| { + std::env::var_os("HEADROOM_LIVE_ZONE_DISABLE_ARMS") + .map(|raw| parse_disabled_arms(&raw.to_string_lossy())) + .unwrap_or_default() + }) +} + +/// True when `content_type`'s dispatch arm has been disabled via +/// `HEADROOM_LIVE_ZONE_DISABLE_ARMS`. +fn arm_disabled(content_type: ContentType) -> bool { + disabled_arms().contains(&content_type) +} + // ─── Public entry point ──────────────────────────────────────────────── /// Inspect a buffered Anthropic `/v1/messages` body and decide which @@ -1318,15 +1620,20 @@ enum DispatchResult { /// Map `(text, content_type)` to the compressor result. /// -/// Per spec PR-B3: +/// Per spec PR-B3 (arms marked PR-B4 wired by that PR): /// /// - `JsonArray` (with `is_dict_array=true`) → SmartCrusher /// - `BuildOutput` → LogCompressor /// - `SearchResults` → SearchCompressor /// - `GitDiff` → DiffCompressor -/// - `SourceCode` → no-op (Rust port pending; see TODO below) -/// - `PlainText` → no-op (PR-B4 wires Kompress) +/// - `SourceCode` → CodeAwareCompressor, falling back to cached +/// Kompress on exact passthrough (PR-B4) +/// - `PlainText` → cached Kompress, cache-cold → no-op (PR-B4) /// - `Html` → no-op (no compressor) +/// +/// Any arm whose content type is named in +/// `HEADROOM_LIVE_ZONE_DISABLE_ARMS` short-circuits to a no-op before +/// the table below is consulted. fn dispatch_compressor(text: &str, content_type: ContentType) -> DispatchResult { if text.is_empty() { return DispatchResult::NoOp { @@ -1334,6 +1641,16 @@ fn dispatch_compressor(text: &str, content_type: ContentType) -> DispatchResult }; } + // Kill switch, checked once for every arm rather than per arm: an + // operator naming any content type gets that arm disabled, instead + // of the switch silently covering only the two arms whose bodies + // happened to consult it. + if arm_disabled(content_type) { + return DispatchResult::NoOp { + content_type: content_type.as_str(), + }; + } + match content_type { ContentType::JsonArray => { // The detector classifies arrays-of-scalars as JsonArray @@ -1387,18 +1704,45 @@ fn dispatch_compressor(text: &str, content_type: ContentType) -> DispatchResult compressed: result.compressed, } } - // TODO(PR-B4 / Rust code-compressor port): Python has a - // CodeAwareCompressor; the Rust port is not yet shipped. Once - // that crate lands, `ContentType::SourceCode` routes here - // exactly as the others above. - ContentType::SourceCode => DispatchResult::NoOp { - content_type: content_type.as_str(), - }, - // TODO(PR-B4): wire Kompress (lossless prose compressor) for - // PlainText. For now, leave untouched. - ContentType::PlainText => DispatchResult::NoOp { + // Routes to the tree-sitter-backed CodeAwareCompressor (PR-B4). + // The compressor fails open internally (re-parse-and-revert on + // syntax errors, a min-token floor, and a compression-ratio + // guard), so this arm only has to compare its output against + // the input; the tokenizer-rejection gate in + // `compress_one_block` applies on top of that. When the code + // compressor doesn't shrink the block (e.g. it's already + // dense, or tree-sitter reverted on a parse error), fall back + // to Kompress rather than giving up — mirrors the Python + // router's no-shrink → Kompress fallback. + ContentType::SourceCode => { + let result = code_compressor().compress(text); + if result.compressed == text { + // Exact passthrough only: the compressor declined + // (min-token floor, unknown language, parse failure, + // ratio guard), so hand the block to Kompress. A + // rewrite that changed bytes without shrinking tokens + // is NOT retried here — `compress_one_block`'s + // tokenizer gate rejects it and forwards the original. + return kompress_or_noop(text, content_type); + } + DispatchResult::Compressed { + strategy: STRATEGY_CODE_COMPRESSOR, + compressed: result.compressed, + } + } + // Cache-only EXTRACTIVE prose compressor (PR-B4): Kompress drops + // low-salience words and rejoins on single spaces — lossy by + // design. `kompress_or_noop` degrades to a deterministic no-op + // when the model isn't cache-resident, hasn't finished its + // background initialization yet, or the `ml` feature is off — + // no network call and no model build ever happens on this path. + // This dispatcher has no tabular compressor; preserve these rows + // instead of sending structured output through lossy Kompress. + ContentType::Tabular => DispatchResult::NoOp { content_type: content_type.as_str(), }, + + ContentType::PlainText => kompress_or_noop(text, content_type), // No HTML compressor on the Rust side; pages are handled by // upstream extractors, not the proxy. ContentType::Html => DispatchResult::NoOp { diff --git a/crates/headroom-core/src/transforms/log_compressor.rs b/crates/headroom-core/src/transforms/log_compressor.rs index baf6ce3be8..3445b465ea 100644 --- a/crates/headroom-core/src/transforms/log_compressor.rs +++ b/crates/headroom-core/src/transforms/log_compressor.rs @@ -1780,6 +1780,54 @@ mod tests { assert_eq!(lines[2].level, LogLevel::Unknown); } + #[test] + fn level_classifier_covers_every_level() { + let c = cmp(); + let cases: &[(&str, LogLevel)] = &[ + ("ERROR: something went wrong", LogLevel::Error), + ("error: file not found", LogLevel::Error), + ("Error: Invalid input", LogLevel::Error), + ("FATAL: system crash", LogLevel::Error), + ("fatal error occurred", LogLevel::Error), + ("CRITICAL: database down", LogLevel::Error), + ("FAIL tests/test_foo.py", LogLevel::Fail), + ("FAILED to connect", LogLevel::Fail), + ("Test failed", LogLevel::Fail), + ("WARN: deprecated function", LogLevel::Warn), + ("WARNING: low disk space", LogLevel::Warn), + ("warning: unused variable", LogLevel::Warn), + ("INFO: starting process", LogLevel::Info), + ("info starting", LogLevel::Info), + ("DEBUG: variable x = 5", LogLevel::Debug), + ("debug mode enabled", LogLevel::Debug), + ("TRACE: entering function", LogLevel::Trace), + ("Just some regular text", LogLevel::Unknown), + ]; + for (line, expected) in cases { + let parsed = c.parse_lines(&[line]); + assert_eq!(parsed[0].level, *expected, "level for {line:?}"); + } + } + + #[test] + fn score_line_orders_levels_and_boosts() { + let line = |level: LogLevel, is_stack_trace: bool, is_summary: bool| LogLine { + line_number: 0, + content: String::new(), + level, + is_stack_trace, + is_summary, + score: 0.0, + }; + let info = score_log_line(&line(LogLevel::Info, false, false)); + assert!(score_log_line(&line(LogLevel::Error, false, false)) > info); + assert!(score_log_line(&line(LogLevel::Fail, false, false)) > info); + assert_eq!(score_log_line(&line(LogLevel::Warn, false, false)), 0.5); + let plain = score_log_line(&line(LogLevel::Unknown, false, false)); + assert!(score_log_line(&line(LogLevel::Unknown, true, false)) > plain); + assert!(score_log_line(&line(LogLevel::Unknown, false, true)) > plain); + } + #[test] fn recognizes_pytest_short_summary_entries_by_section_and_line_number() { let lines = [ diff --git a/crates/headroom-core/src/transforms/search_compressor.rs b/crates/headroom-core/src/transforms/search_compressor.rs index a6e4f329cc..7be7f417d4 100644 --- a/crates/headroom-core/src/transforms/search_compressor.rs +++ b/crates/headroom-core/src/transforms/search_compressor.rs @@ -71,8 +71,7 @@ use md5::{Digest, Md5}; use crate::ccr::CcrStore; use crate::signals::{ImportanceContext, LineImportanceDetector}; -/// True for CJK ideographs, kana, and Hangul. Code-point ranges kept -/// byte-identical with the Python `_is_cjk_char` for search-compressor parity. +/// True for CJK ideographs, kana, and Hangul. fn is_cjk_char(c: char) -> bool { matches!( c as u32, @@ -81,7 +80,7 @@ fn is_cjk_char(c: char) -> bool { } /// CJK character bigrams from the CJK runs of a (lowercased) query, so a -/// spaceless CJK query can match content. Mirrors the Python `_cjk_bigrams`. +/// spaceless CJK query can match content. fn cjk_bigrams(text: &str) -> BTreeSet { let mut out = BTreeSet::new(); let mut run: Vec = Vec::new(); @@ -452,9 +451,8 @@ impl SearchCompressor { bias: f64, stats: &mut SearchCompressorStats, ) -> BTreeMap { - // Python `_select_matches` sorts files by total match score - // descending. `BTreeMap` iterates in key order, so we collect - // and sort explicitly. + // Sort files by total match score, descending. `BTreeMap` + // iterates in key order, so we collect and sort explicitly. let mut by_score: Vec<(&String, &FileMatches)> = files.iter().collect(); by_score.sort_by(|a, b| { b.1.total_score() @@ -648,7 +646,10 @@ impl SearchCompressor { /// practically never contains one (the Windows drive colon is /// already skipped above), so the leftmost is the right one. The /// no-whitespace guard keeps a `foo.rs:12:` reference *inside the -/// body* of a `-`-separated context line from hijacking the parse. +/// body* of a `-`-separated context line from hijacking the parse. A +/// whitespace-free body reference (`a:7:b`) still slips past it, so +/// [`parse_match_line`] hands priority back to the dash tier for that +/// one shape; see the comment there. /// 2. **Dash tier** — `-` is grep's *context*-line separator, and unlike /// `:` it appears inside real paths (`2026-05-03`, `CVE-2021-44228`, /// `20240101-002-add_users.sql`), so the leftmost triplet is often @@ -684,9 +685,50 @@ impl SearchCompressor { /// Only reached when neither tier matched (e.g. a path containing a /// space), so behaviour for those lines is exactly as before. fn parse_match_line(line: &str) -> Option<(&str, u64, &str)> { - scan_match_line(line, ScanTier::Colon) - .or_else(|| scan_match_line(line, ScanTier::Dash)) - .or_else(|| scan_match_line(line, ScanTier::Permissive)) + let colon = scan_marker(line, ScanTier::Colon); + let dash = scan_marker(line, ScanTier::Dash); + + // The colon tier keeps priority: `path:line:content` is what grep emits for + // matches and a path practically never contains a colon. It yields in + // exactly one shape — a `-`-separated context line whose body carries a + // whitespace-free `name:N:` reference (`app.py-476-a:7:b`). The dash tier + // *positively confirmed* a boundary before the colon marker, so that colon + // triplet is body text, not a marker. + // + // Without the hand-off the body's number is read as the line number, the + // path swallows the real `-N-`, and the model is shown a file that does not + // exist holding content attributed to a line it never came from + // (issue #3545). The whitespace guard inside the colon tier cannot catch + // this, because the body ahead of the reference need not contain any. + // + // The `true` in the dash arm is load-bearing, and it is the *whole* test. + // A confirmed boundary means the dash tier saw the path end there — either + // the segment carried an extension (`app.py-476-`) or nothing after the + // marker looked like path structure (`CHANGELOG-12-`) — so the bytes from + // the closing dash onwards are body text and may look like anything. Bodies + // routinely carry a filename-style reference of their own + // (`app.py-476-foo.rs:12:ref`), and an extension dot there says nothing + // about where the path ended. + // + // The rows that must keep the colon tier are the ones where the dash tier + // could *not* confirm: `logs/2026-05-03/app.log:12:ERROR` and + // `migrations/20240101-002-add_users.sql:12:SELECT` both leave the digits + // inside the path, so the flag is false and the hand-off never applies. See + // [`path_continues`], which is what decides the flag. + let dash_overrides = match (colon, dash) { + (Some((colon_marker, _)), Some((dash_marker, true))) => colon_marker.0 > dash_marker.2, + _ => false, + }; + + let marker = if dash_overrides { + dash + } else { + colon + .or(dash) + .or_else(|| scan_marker(line, ScanTier::Permissive)) + }; + + build_match(line, marker?.0) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -752,7 +794,22 @@ fn path_continues(rest: &str) -> bool { .is_some_and(|tok| tok.contains('/') || tok.contains('\\') || has_extension_dot(tok)) } -fn scan_match_line(line: &str, tier: ScanTier) -> Option<(&str, u64, &str)> { +/// Byte offsets of one `` marker: where the path ends, where +/// the digits start, and where the closing separator sits. +type Marker = (usize, usize, usize); + +/// Assemble `(file, line_number, content)` from a marker's byte offsets. +fn build_match(line: &str, marker: Marker) -> Option<(&str, u64, &str)> { + let (path_end, digits_start, digits_end) = marker; + let line_no = line[digits_start..digits_end].parse::().ok()?; + Some((&line[..path_end], line_no, &line[digits_end + 1..])) +} + +/// Scan `line` under `tier`, returning the marker it settled on plus whether +/// that marker was a *positively confirmed* boundary (`chosen`) rather than the +/// leftmost fallback (`first`). Callers need that flag to tell a proven boundary +/// from a guess. +fn scan_marker(line: &str, tier: ScanTier) -> Option<(Marker, bool)> { let bytes = line.as_bytes(); // Windows drive prefix: starts with [A-Za-z]:[\\/] let scan_start = if bytes.len() >= 3 @@ -865,11 +922,7 @@ fn scan_match_line(line: &str, tier: ScanTier) -> Option<(&str, u64, &str)> { // is genuinely ambiguous (`Makefile-7-include-2-src/foo.mk`). Fall back // to the leftmost marker — precisely what the leftmost-wins rule this // tier refines would have returned. - let (path_end, digits_start, digits_end) = chosen.or(first)?; - let line_no = std::str::from_utf8(&bytes[digits_start..digits_end]) - .ok() - .and_then(|s| s.parse::().ok())?; - Some((&line[..path_end], line_no, &line[digits_end + 1..])) + Some((chosen.or(first)?, chosen.is_some())) } // ─── Internals ────────────────────────────────────────────────────────── @@ -918,6 +971,60 @@ mod tests { assert!(cjk_bigrams("a认b证").is_empty()); // isolated CJK chars -> no pair } + #[test] + fn dash_context_line_wins_over_a_colon_marker_in_its_body() { + // A whitespace-free `name:N:` reference in the body of a `-`-separated + // context line used to be read as the line-number marker: the path + // absorbed the real `-N-` and the body's number was reported as the + // line number, inventing both a file that does not exist and a + // line<->content pair that never existed (issue #3545). + assert_eq!( + parse_line("app/settings.py-476-a:7:b:8:c"), + Some(("app/settings.py".into(), 476, "a:7:b:8:c".into())) + ); + // Extension-less paths reach the dash tier through its negative stop + // rather than the extension rule, and must keep resolving. + assert_eq!( + parse_line("CHANGELOG-12-a:99:b"), + Some(("CHANGELOG".into(), 12, "a:99:b".into())) + ); + // A body reference that looks like a *filename* is the canonical form + // the hand-off used to miss. `app.py-476-foo.rs:12:ref` is a context + // row for `app.py` line 476 whose body is `foo.rs:12:ref`. The dash + // tier confirms the boundary (the segment carries an extension), so the + // extension dot inside the body must not veto it — before the fix the + // colon tier reclaimed the row as path `app.py-476-foo.rs`, line 12. + assert_eq!( + parse_line("app.py-476-foo.rs:12:ref"), + Some(("app.py".into(), 476, "foo.rs:12:ref".into())) + ); + // Same shape behind a directory, and with a Windows separator in the + // body reference. + assert_eq!( + parse_line("pkg/server.ts-91-lib/index.js:7:import"), + Some(("pkg/server.ts".into(), 91, "lib/index.js:7:import".into())) + ); + // The body may even be a bare path with no reference at all. + assert_eq!( + parse_line("app.py-476-./vendor/other.py"), + Some(("app.py".into(), 476, "./vendor/other.py".into())) + ); + // A colon row whose *path* holds the dash triplet is untouched: the + // dash tier never confirmed a boundary there, so the colon tier wins. + assert_eq!( + parse_line("logs/2026-05-03/app.log:12:ERROR"), + Some(("logs/2026-05-03/app.log".into(), 12, "ERROR".into())) + ); + assert_eq!( + parse_line("migrations/20240101-002-add_users.sql:12:SELECT"), + Some(( + "migrations/20240101-002-add_users.sql".into(), + 12, + "SELECT".into() + )) + ); + } + #[test] fn dash_tier_does_not_run_past_the_path_into_the_body() { // The dash tier keeps the *last* `--` marker so that a @@ -1254,8 +1361,8 @@ src/main.py-44-context line"; #[test] fn select_respects_per_file_cap_and_global_cap() { - // Note: compute_optimal_k enforces a hard `min_k=5` floor (matches - // Python `_select_matches`), so `max_total_matches` is a soft cap + // Note: compute_optimal_k enforces a hard `min_k=5` floor, so + // `max_total_matches` is a soft cap // that bites only above that floor. Configure 6 here to exercise // the cap path. let compressor = SearchCompressor::new(SearchCompressorConfig { diff --git a/crates/headroom-core/src/transforms/smart_crusher/compaction/formatter.rs b/crates/headroom-core/src/transforms/smart_crusher/compaction/formatter.rs index dcb085e7be..22771448f2 100644 --- a/crates/headroom-core/src/transforms/smart_crusher/compaction/formatter.rs +++ b/crates/headroom-core/src/transforms/smart_crusher/compaction/formatter.rs @@ -282,17 +282,31 @@ fn write_table( } out.push('\n'); + // Cells of a `json` column are decoded with `json.loads`, so a string + // that looks like a JSON literal needs its own quoting there. + let json_cols: Vec = schema.fields.iter().map(|f| f.type_tag == "json").collect(); + // Rows. for row in rows { - let cells: Vec = row.0.iter().map(format_cell).collect(); + let cells: Vec = row + .0 + .iter() + .enumerate() + .map(|(i, c)| format_cell(c, json_cols.get(i).copied().unwrap_or(false))) + .collect(); out.push_str(&cells.join(",")); out.push('\n'); } } -fn format_cell(c: &CellValue) -> String { +fn format_cell(c: &CellValue, json_col: bool) -> String { match c { CellValue::Missing => String::new(), + CellValue::Scalar(Value::String(s)) if json_col && is_ambiguous_json_text(s) => { + // Render as a JSON string literal (then CSV-quoted) so the + // decoder gets this string back, not the value it spells. + csv_quote(&serde_json::to_string(s).unwrap_or_default()) + } CellValue::Scalar(v) => json_scalar_to_csv(v), CellValue::Nested(sub) => { // Render nested as compact JSON; CSV-quote because it @@ -356,6 +370,17 @@ fn json_scalar_to_csv(v: &Value) -> String { } } +/// True when `json.loads` could parse `s` into something other than the +/// string itself. A conservative superset: after trimming JSON white +/// space, `s` starts with `[`, `{`, `"`, `-` or a digit, or is one of the +/// bare literals Python accepts (`true`, `false`, `null`, `NaN`, +/// `Infinity`). +fn is_ambiguous_json_text(s: &str) -> bool { + let t = s.trim_matches(|c| matches!(c, ' ' | '\t' | '\n' | '\r')); + matches!(t.chars().next(), Some('[' | '{' | '"' | '-' | '0'..='9')) + || matches!(t, "true" | "false" | "null" | "NaN" | "Infinity") +} + fn needs_csv_quote(s: &str) -> bool { s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') } @@ -766,6 +791,118 @@ mod tests { assert_ne!(fa, fb); } + /// Wire text of the single data row of a one-column table. + fn one_cell_wire(type_tag: &str, cell: Value) -> String { + let c = Compaction::Table { + schema: Schema { + fields: vec![super::super::ir::FieldSpec { + name: "v".into(), + type_tag: type_tag.into(), + nullable: false, + }], + }, + rows: vec![Row::new(vec![CellValue::Scalar(cell)])], + original_count: 1, + }; + let out = CsvSchemaFormatter::new().format(&c); + out.lines().nth(1).unwrap().to_string() + } + + #[test] + fn csv_formatter_json_column_quotes_strings_that_parse_as_json() { + // The cells are built by hand to pin the formatter's contract. Through + // `compact`, a string that parses as a JSON array or object (`[1]`, + // `{"a":1}`) never reaches the formatter as a string: the classifier + // un-escapes it first (`CellClass::StringifiedJson`). + // (string value, the JSON string literal it is rendered as) + let cases = [ + ("null", r#""null""#), + ("true", r#""true""#), + ("false", r#""false""#), + ("1", r#""1""#), + ("-1", r#""-1""#), + ("1.5", r#""1.5""#), + ("NaN", r#""NaN""#), + ("Infinity", r#""Infinity""#), + ("-Infinity", r#""-Infinity""#), + (" 2 ", r#"" 2 ""#), + ("[1]", r#""[1]""#), + (r#"{"a":1}"#, r#""{\"a\":1}""#), + (r#""q""#, r#""\"q\"""#), + ]; + for (s, literal) in cases { + let wire = one_cell_wire("json", Value::String(s.into())); + assert_eq!(wire, csv_quote(literal), "json column, string {s:?}"); + // The control: other column types keep the old rendering. + let control = one_cell_wire("string", Value::String(s.into())); + assert_eq!( + control, + json_scalar_to_csv(&Value::String(s.into())), + "string column, string {s:?}" + ); + } + assert_eq!( + one_cell_wire("json", Value::String("null".into())), + r#""""null""""# + ); + assert_eq!( + one_cell_wire("string", Value::String("null".into())), + r#""null""# + ); + assert_eq!( + one_cell_wire("string", Value::String("true".into())), + "true" + ); + } + + #[test] + fn csv_formatter_json_column_leaves_other_strings_alone() { + let cases = [ + ("hello", "hello"), + ("a,b", r#""a,b""#), + (r#"q"uote"#, r#""q""uote""#), + (" ", " "), + ("x-y", "x-y"), + ("", r#""""#), + ]; + for (s, wire) in cases { + assert_eq!( + one_cell_wire("json", Value::String(s.into())), + wire, + "json column, string {s:?}" + ); + } + } + + #[test] + fn csv_formatter_json_column_tells_strings_from_values() { + let items = vec![ + json!({"id": 1, "v": "null"}), + json!({"id": 2, "v": null}), + json!({"id": 3, "v": "true"}), + json!({"id": 4, "v": true}), + json!({"id": 5, "v": "1"}), + json!({"id": 6, "v": 1}), + json!({"id": 7, "v": "hello"}), + ]; + let c = compact(&items, &cfg()); + let out = CsvSchemaFormatter::new().format(&c); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "[7]{id:int,v:json?}", "got: {out}"); + assert_eq!(lines[1], r#"1,"""null""""#); + assert_eq!(lines[2], "2,null"); + assert_eq!(lines[3], r#"3,"""true""""#); + assert_eq!(lines[4], "4,true"); + assert_eq!(lines[5], r#"5,"""1""""#); + assert_eq!(lines[6], "6,1"); + assert_eq!(lines[7], "7,hello"); + // The string "true" and the bool true no longer share a wire form. + assert_ne!( + lines[3].split_once(',').unwrap().1, + lines[4].split_once(',').unwrap().1 + ); + } + #[test] fn estimate_matches_format_len() { let items = vec![json!({"a": 1}), json!({"a": 2})]; diff --git a/crates/headroom-core/src/transforms/smart_crusher/crusher.rs b/crates/headroom-core/src/transforms/smart_crusher/crusher.rs index 9135884fdd..ff5624760d 100644 --- a/crates/headroom-core/src/transforms/smart_crusher/crusher.rs +++ b/crates/headroom-core/src/transforms/smart_crusher/crusher.rs @@ -27,7 +27,7 @@ //! - **Telemetry**: no-op. //! - **`_compress_text_within_items`**: pass-through (returns input //! unchanged) since text compression has its own port pipeline. -//! - **`summarize_dropped_items`**: empty string. +//! - **Dropped-item summary**: empty string. //! //! Parity fixtures will be recorded with all four disabled on the //! Python side, locking byte-equal output. The TOIN/CCR/feedback diff --git a/crates/headroom-core/tests/common/mod.rs b/crates/headroom-core/tests/common/mod.rs new file mode 100644 index 0000000000..0b51c48985 --- /dev/null +++ b/crates/headroom-core/tests/common/mod.rs @@ -0,0 +1,171 @@ +//! Shared fixtures for the live-zone integration tests and their +//! dedicated single-test binaries. +//! +//! Integration test files are separate crates, so each `mod common;` +//! gets its own copy of this module — the standard Rust layout for +//! sharing test helpers (The Book, ch. 11.3). Before this module the +//! same body builders, Python/prose generators and HuggingFace-cache +//! probes were pasted into four targets, where the cache probe in +//! particular had to be kept byte-compatible with the production +//! loader by hand. +//! +//! Not every target uses every helper, so unused items here are +//! expected rather than a defect. +#![allow(dead_code)] + +use headroom_core::transforms::live_zone::DEFAULT_MODEL; +use headroom_core::transforms::{ + compress_anthropic_live_zone, AuthMode, BlockAction, CompressionManifest, LiveZoneOutcome, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +/// Serialize a JSON value to a request body. +pub fn body_of(value: &Value) -> Vec { + serde_json::to_vec(value).expect("fixture body serializes") +} + +/// Run the public dispatcher entry point with no frozen prefix. +pub fn dispatch(body: &[u8]) -> LiveZoneOutcome { + compress_anthropic_live_zone(body, 0, AuthMode::Payg, DEFAULT_MODEL) + .expect("dispatcher returns Ok on valid bodies") +} + +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().into() +} + +/// Byte range of the first occurrence of `needle` in `haystack`, +/// half-open. Used to locate the JSON-encoded `content` slot for +/// byte-fidelity assertions. +pub fn find_byte_range(haystack: &[u8], needle: &[u8]) -> (usize, usize) { + let pos = haystack + .windows(needle.len()) + .position(|w| w == needle) + .unwrap_or_else(|| { + panic!( + "needle of {} bytes not found in haystack of {} bytes", + needle.len(), + haystack.len() + ) + }); + (pos, pos + needle.len()) +} + +/// A body with one user message holding one `tool_result` whose +/// `content` is `text`. Returns the body and the byte range of the +/// JSON-encoded content slot (quotes included) inside it. +pub fn body_with_tool_result(text: &str) -> (Vec, (usize, usize)) { + let body = body_of(&json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 64, + "system": "you are a helpful assistant", + "messages": [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_live_zone_test", + "content": text, + }], + }], + })); + // The encoded slot is exactly `to_vec(&text)`: serde uses the same + // string encoding for the embedded value. + let needle = serde_json::to_vec(&text).expect("text serializes"); + let range = find_byte_range(&body, &needle); + (body, range) +} + +/// The `tool_result` block's action from a manifest, cloned out. +pub fn tool_result_action(manifest: &CompressionManifest) -> BlockAction { + manifest + .block_outcomes + .iter() + .find(|b| b.block_type == "tool_result") + .expect("tool_result block present in manifest") + .action + .clone() +} + +/// Syntactically valid Python with `n` small functions, each with a +/// docstring and a body longer than `CodeCompressorConfig`'s default +/// `max_body_lines` (5) so body elision has something to trim. Real +/// Python: the compressor re-parses and reverts on syntax errors, so a +/// fixture that merely looks like code would silently pass through. +pub fn python_module_source(n: usize) -> String { + use std::fmt::Write as _; + + let mut code = String::from( + "\"\"\"Example data-processing module used by the live-zone tests.\"\"\"\n\n\ + import json\n\ + import os\n\ + from typing import Any, Optional\n\n\n", + ); + for i in 0..n { + let _ = write!( + code, + "def process_record_{i}(record: dict) -> dict:\n \ + \"\"\"Normalize record {i} and compute its derived fields.\"\"\"\n \ + result = dict(record)\n \ + result[\"index\"] = {i}\n \ + result[\"doubled\"] = record.get(\"value\", 0) * 2\n \ + result[\"source\"] = \"batch\"\n \ + if result[\"doubled\"] > 100:\n \ + result[\"flag\"] = \"high\"\n \ + else:\n \ + result[\"flag\"] = \"low\"\n \ + return result\n\n\n" + ); + } + code +} + +/// Repetitive plain prose of at least `min_bytes`, varied enough to +/// classify as `PlainText`. +pub fn plain_prose(min_bytes: usize) -> String { + use std::fmt::Write as _; + + let mut text = String::with_capacity(min_bytes + 256); + let mut i = 0usize; + while text.len() < min_bytes { + let _ = write!( + text, + "City officials announced today that the downtown revitalization \ + project will proceed as planned despite budget concerns raised \ + during round {i} of public comment. " + ); + i += 1; + } + text +} + +/// A JSON array of dicts, `n` entries — SmartCrusher's shape. +pub fn json_array_of_dicts(n: usize) -> String { + let rows: Vec = (0..n) + .map(|i| { + json!({ + "id": i, + "status": "ok", + "value": format!("repeat-pattern-{}", i % 3), + }) + }) + .collect(); + serde_json::to_string(&rows).expect("fixture array serializes") +} + +/// Whether the Kompress model is cache-resident and loaded, asked of the +/// production slot itself: this runs `live_zone`'s blocking warmup — the +/// proxy's own startup path — and reports what it found, rather than +/// re-deriving HuggingFace cache paths in a probe that could desync. +/// +/// Call it BEFORE dispatching content that should reach the arm. +/// Dispatch never waits on model construction, so an unwarmed first +/// dispatch is a NoOp by design — the contract pinned by +/// `live_zone_kompress_async_init.rs`. Warming here both settles the +/// slot the dispatch under test will read and prices the probe at one +/// shared construction instead of a throwaway one. +pub fn kompress_available() -> bool { + headroom_core::transforms::live_zone::warm_live_zone_compressors() +} diff --git a/crates/headroom-core/tests/live_zone_arm_properties.rs b/crates/headroom-core/tests/live_zone_arm_properties.rs new file mode 100644 index 0000000000..9885077d5c --- /dev/null +++ b/crates/headroom-core/tests/live_zone_arm_properties.rs @@ -0,0 +1,823 @@ +//! Property coverage for the PR-B4 dispatch arms (`SourceCode` → +//! `CodeAwareCompressor`, `PlainText` → Kompress, cache-only) plus the +//! round-trip test that closes the kill-switch alias bug class. +//! +//! Background: `ContentType`'s string parsing used to accept only each +//! variant's `as_str()` tag, so an `HEADROOM_LIVE_ZONE_DISABLE_ARMS` +//! token spelled the "natural" way a human is more likely to write +//! (e.g. `plain_text`, `search_results`) silently failed to match. The +//! token fell through to the unknown-token branch, the arm was never +//! disabled, and there was no error — a misconfiguration that looked +//! like a no-op. Both spellings now parse (`ContentType`'s `FromStr`), +//! and the round-trip test below pins that for every variant by +//! iterating `ContentType::ALL` rather than a hand-maintained copy of +//! the variant list. +//! +//! Property-test coverage for the dispatch arms themselves follows +//! below: no-panic and determinism properties (proptest), plus a +//! byte-fidelity test for the SourceCode arm cloned from +//! `live_zone_dispatch.rs::byte_fidelity_outside_compressed_block`, plus +//! an instrumentation test that measures — rather than +//! asserts — what fraction of generated cases actually reach a dispatch +//! arm, broken down by content type. House style for the proptest +//! blocks mirrors `live_zone_token_validation.rs:201-254` — dispatch +//! only through the public `compress_anthropic_live_zone` entry point +//! over generated `tool_result` bodies, never the private +//! `dispatch_compressor` — and the no-panic parser fuzz tests in +//! `headroom-proxy/tests/sse_framing.rs:156-200` for case-count order +//! of magnitude and the comment style explaining the choice. + +mod common; + +use common::{body_with_tool_result, dispatch, python_module_source, sha256}; +use headroom_core::transforms::live_zone::threshold_for; +use headroom_core::transforms::{detect_content_type, BlockAction, ContentType, LiveZoneOutcome}; +use proptest::prelude::*; +use proptest::strategy::ValueTree; +use proptest::test_runner::{RngAlgorithm, TestRng, TestRunner}; +use serde_json::Value; + +// ─── Part 0: kill-switch alias round trip (bug class extinction) ────── + +/// The bug class this test makes extinct: a valid natural-name spelling +/// of a `ContentType` failing to parse, so +/// `HEADROOM_LIVE_ZONE_DISABLE_ARMS` looks like it disabled an arm but +/// didn't. +/// +/// Iterates `ContentType::ALL`, so a variant added later is covered +/// here automatically — the earlier version of this test carried its +/// own hand-maintained variant array plus a mirrored count and a +/// compile-time length assertion, none of which could actually tell +/// that a new variant had gone missing from the array. +#[test] +fn content_type_parses_from_both_spellings_for_all_variants() { + for content_type in ContentType::ALL { + let tag = content_type.as_str(); + assert_eq!( + tag.parse::(), + Ok(content_type), + "as_str() tag {tag:?} must round-trip back to {content_type:?}" + ); + + let natural = content_type.natural_name(); + assert_eq!( + natural.parse::(), + Ok(content_type), + "natural-name alias {natural:?} must parse to {content_type:?}" + ); + } +} + +/// An unknown name must be a parse error, not a silently-wrong variant. +#[test] +fn content_type_rejects_unknown_names() { + for name in ["", "bogus_type", "Source_Code", "SOURCE_CODE", "plaintext"] { + assert!( + name.parse::().is_err(), + "{name:?} must not parse to a ContentType" + ); + } +} + +// ─── Part 1: pathological-text generators (properties 1 & 2) ────────── + +/// Pure-ASCII source fragment used by the "half-truncated code +/// snippet" arm of [`pathological_text`]. ASCII-only so every byte +/// index is also a valid `char` boundary — slicing at an arbitrary +/// length can never panic on a UTF-8 boundary violation. +const CODE_FRAGMENT: &str = "def handler(event, context):\n \ +payload = json.loads(event[\"body\"])\n \ +if payload.get(\"kind\") == \"ping\":\n \ +return {\"statusCode\": 200, \"body\": \"pong\"}\n \ +result = process(payload)\n \ +return {\"statusCode\": 200, \"body\": json.dumps(result)}\n"; + +/// Function-name pool for the "code-like" generator bucket's varied +/// identifiers — deliberately NOT the +/// single `process_record_{i}` pattern `python_module_source` uses for +/// its fixed byte-fidelity fixture. +const CODE_IDENTIFIERS: &[&str] = &[ + "process_record", + "normalize_entry", + "compute_score", + "parse_payload", + "build_summary", + "validate_input", + "apply_filter", + "merge_results", + "extract_fields", + "transform_row", + "classify_item", + "enrich_context", + "dedupe_values", + "flatten_tree", + "sanitize_text", + "batch_update", + "load_config", + "fetch_metadata", + "index_documents", + "score_candidate", + "resolve_alias", + "collect_stats", + "prune_stale", + "rank_matches", + "join_segments", + "split_batch", + "verify_checksum", + "encode_payload", + "decode_payload", + "queue_task", +]; + +/// Render one function: a docstring, then `body_lines` body statements +/// (floored at 2: `result = ...` plus `return result`), then a blank +/// line. `body_lines` deliberately spans both sides of +/// `CodeCompressorConfig::default().max_body_lines` (5) — a whole +/// function node of `<= max_body_lines + 2 == 7` lines passes through +/// `compress_function_ast` untouched, longer ones get their body +/// collapsed — so callers can generate a mix of both shapes in one +/// module instead of the "always long enough to collapse" shape +/// `python_module_source` uses. +fn render_function(name: &str, index: usize, body_lines: usize) -> String { + let body_lines = body_lines.max(2); + let filler = body_lines - 2; + let mut out = String::new(); + out.push_str(&format!("def {name}_{index}(record: dict) -> dict:\n")); + out.push_str(&format!( + " \"\"\"Derive fields for {name} #{index}.\"\"\"\n" + )); + out.push_str(" result = dict(record)\n"); + for i in 0..filler { + out.push_str(&format!(" result[\"{name}_{i}\"] = {i}\n")); + } + out.push_str(" return result\n"); + out.push('\n'); + out +} + +/// Syntactically-plausible Python, 2048-6000 bytes, with VARIED +/// structure: differing function counts and body lengths straddling +/// the CodeAwareCompressor's 5-line collapse floor, varied identifiers +/// drawn from `CODE_IDENTIFIERS`. Added after a review measured that +/// before this bucket existed, +/// 0% of `pathological_text()`'s generated cases ever reached the +/// SourceCode dispatch arm (the fixed `python_module_source(10)` +/// fixture in `byte_fidelity_outside_compressed_source_block` was +/// SourceCode's only exercise anywhere in this file). +/// +/// Always classifies `SourceCode` — but NOT because of the header +/// alone: `try_detect_code`'s confidence is +/// `0.4 + (matching_lines / non_empty_lines) * 0.4 + matching_lines * 0.02`, +/// so a fixed count of header matches DILUTES below the 0.5 floor once +/// enough non-matching lines follow (~4 matches over 100 non-empty +/// lines ≈ 0.496). What actually holds classification is that every +/// `render_function` body emits `def ...:` / docstring lines that also +/// match `CODE_PATTERNS`, keeping `matching_lines` roughly proportional +/// to length. Consequence for future edits: rewriting `render_function` +/// with shapes that DON'T match the detector's patterns (e.g. +/// assignment-bound lambdas) would silently reclassify large samples as +/// PlainText — `dispatch_reach_fractions_meet_floor` fails loudly when +/// that happens; believe it, and look here first. +/// +/// Byte-length mechanism: pick a `target_bytes` first, then append +/// functions from a pool of varied specs until the buffer crosses it +/// (each function bounded to at most ~510 bytes by `body_lines`'s 1..=10 +/// range, so the overshoot past `target_bytes`'s 5000 ceiling can't +/// reach the outer 6000 bound). If the pool shrinks smaller than +/// `target_bytes` needs (proptest's shrinker actively tries this), a +/// deterministic filler function pads the buffer past the 2048 floor +/// regardless — this keeps the "always >= 2048 bytes, always +/// SourceCode" invariant true even for a minimized failing case, so a +/// shrunk counterexample stays inside the region that made it +/// interesting in the first place. +fn code_like_source() -> impl Strategy { + ( + 2_048usize..=5_000, + proptest::collection::vec( + (proptest::sample::select(CODE_IDENTIFIERS), 1usize..=10), + 1..=200, + ), + ) + .prop_map(|(target_bytes, specs)| { + let mut code = String::from( + "\"\"\"Generated module for the code-like dispatch-reach bucket.\"\"\"\n\n\ + import json\n\ + import os\n\ + from typing import Any, Optional\n\n\n", + ); + for (index, (name, body_lines)) in specs.into_iter().enumerate() { + if code.len() >= target_bytes { + break; + } + code.push_str(&render_function(name, index, body_lines)); + } + // Guaranteed floor (see doc comment above): pad with a + // deterministic filler function so this bucket always + // clears THRESHOLD_SOURCE_CODE (2048), even for a pool too + // small (or too shrunk) to reach `target_bytes` on its own. + let mut filler_index = 10_000usize; + while code.len() < 2_048 { + code.push_str(&render_function("filler", filler_index, 8)); + filler_index += 1; + } + code + }) +} + +/// Vocabulary for the "prose-like" generator bucket: ordinary lowercase +/// English words chosen to have NO overlap with any content_detector.rs +/// keyword or symbol (`def`, `class`, `import`, `ERROR`, `diff`, `<`, +/// `[`, `:`, `@`, ...), so generated text is guaranteed to fall through +/// every specialized detector (JSON / diff / HTML / search / log / +/// code) and land on `PlainText`'s default branch. +const PROSE_WORDS: &[&str] = &[ + "the", + "team", + "reviewed", + "quarterly", + "report", + "and", + "found", + "several", + "interesting", + "trends", + "worth", + "discussing", + "further", + "during", + "next", + "weeks", + "meeting", + "customers", + "have", + "been", + "asking", + "about", + "new", + "features", + "that", + "would", + "improve", + "their", + "daily", + "workflow", + "without", + "adding", + "unnecessary", + "complexity", + "to", + "existing", + "processes", + "engineers", + "spent", + "most", + "of", + "afternoon", + "debugging", + "a", + "tricky", + "issue", + "related", + "caching", + "behavior", + "under", + "heavy", + "load", + "documentation", + "was", + "updated", + "reflect", + "recent", + "changes", + "in", + "policy", + "procedure", + "across", + "departments", + "stakeholders", + "expressed", + "cautious", + "optimism", + "regarding", + "timeline", + "for", + "launch", + "despite", + "lingering", + "concerns", + "budget", + "allocation", + "remains", + "topic", + "ongoing", + "discussion", + "among", + "leadership", + "training", + "materials", + "were", + "distributed", + "all", + "staff", + "ahead", + "upcoming", + "transition", + "period", + "feedback", + "collected", + "from", + "survey", + "suggests", + "broad", + "satisfaction", + "with", + "current", + "support", + "channels", + "seasonal", + "demand", + "typically", + "increases", + "toward", + "end", + "fiscal", + "year", + "requiring", + "additional", + "planning", + "resources", + "roadmap", + "priorities", + "shifted", + "slightly", + "after", + "user", + "research", + "revealed", + "unexpected", + "usage", + "patterns", + "warrant", + "deeper", + "study", + "onboarding", + "experience", + "still", + "feels", + "clunky", + "several", + "early", + "testers", + "noted", + "confusion", + "around", + "default", + "settings", + "release", + "notes", + "circulated", + "internally", + "before", + "public", + "announcement", + "went", + "out", + "later", + "same", + "week", +]; + +/// Prose-like text, 5200-11800 bytes, built from `PROSE_WORDS` with +/// varied sentence/paragraph shape — deliberately NOT a single repeated +/// character, unlike the pre-existing `pathological_text()` bucket that +/// repeats `'x'` 1000-8000 times. Added by the same review as +/// `code_like_source`: the problem with the old repeated-`'x'` +/// bucket was that it only reaches the PlainText arm by luck of length +/// crossing 5120 bytes, not because it looks like prose — this bucket +/// looks like prose AND always clears the threshold. +/// +/// Same target-then-fill-then-pad mechanism as `code_like_source` +/// (see its doc comment): the length floor (5200 bytes, comfortably +/// above `THRESHOLD_PLAIN_TEXT`'s 5120) holds even for a shrunk word +/// pool, so a minimized counterexample from this bucket stays inside +/// the region that made it interesting. +fn prose_like_text() -> impl Strategy { + ( + 5_200usize..=11_800, + proptest::collection::vec(proptest::sample::select(PROSE_WORDS), 1..=2_200), + ) + .prop_map(|(target_bytes, words)| { + let mut text = String::with_capacity(target_bytes + 64); + let mut words_in_sentence = 0u32; + for word in words { + if text.len() >= target_bytes { + break; + } + text.push_str(word); + words_in_sentence += 1; + // Vary sentence/paragraph shape instead of one giant + // run-on line: end a "sentence" every ~9 words. + if words_in_sentence >= 9 { + text.push_str(".\n"); + words_in_sentence = 0; + } else { + text.push(' '); + } + } + while text.len() < 5_200 { + text.push_str("padding "); + } + text + }) +} + +/// Strategy generating "pathological" text for the dispatcher's +/// no-panic and determinism properties below. Mixes: +/// +/// - plain arbitrary strings (the common case, still worth covering; +/// proptest 1.11.0's `any::()` caps at 32 chars, so this +/// bucket never clears any dispatch threshold on its own), +/// - control characters (0x00-0x1F, capped ~256 bytes) — the sort of +/// byte a shell or log scraper can hand a tool_result without ever +/// going through a terminal's escaping, +/// - unpaired UTF-16 surrogates (capped ~384 bytes), repaired via +/// `String::from_utf16_lossy` (a Rust `String` can't hold an actual +/// lone surrogate — this is the closest in-process torture test: what +/// a naive UTF-16 → UTF-8 bridge upstream would hand us after +/// "fixing" a bad pair), +/// - half-truncated code snippets (capped ~272 bytes; a coding agent's +/// tool output cut off mid-token is a realistic production shape, not +/// just a fuzz artifact), +/// - very long single lines with no newlines, 1000-8000 chars (minified +/// JS, a base64 blob, ...) — before the two rich buckets below were +/// added, the only bucket that +/// could ever clear a threshold (PlainText's 5120, when the sampled +/// length lands >= 5120, ~41% of this bucket's own range), +/// - `code_like_source()` — syntactically +/// plausible, varied-structure Python that always clears the +/// SourceCode threshold (2048) and always detects as SourceCode, +/// - `prose_like_text()` — varied +/// natural-language-shaped text that always clears the PlainText +/// threshold (5120) and always detects as PlainText. +/// +/// Weights are set so the two rich buckets carry +/// enough weight to matter (see `dispatch_reach_fractions_meet_floor` +/// for the measured, instrumented result) while keeping every small +/// pathological bucket from before — the no-panic envelope over +/// encoding/detection/threshold-comparison logic on genuinely +/// pathological BYTES (not just large-but-tame text) is still valuable +/// in its own right. +fn pathological_text() -> impl Strategy { + prop_oneof![ + 2 => any::(), + 1 => proptest::collection::vec(0u8..0x20u8, 0..256) + .prop_map(|bytes| bytes.into_iter().map(|b| b as char).collect()), + 1 => proptest::collection::vec(any::(), 0..128) + .prop_map(|units| String::from_utf16_lossy(&units)), + 1 => (0..=CODE_FRAGMENT.len()).prop_map(|n| CODE_FRAGMENT[..n].to_string()), + 1 => (1_000usize..8_000).prop_map(|n| "x".repeat(n)), + 2 => code_like_source(), + 2 => prose_like_text(), + ] +} + +// The dispatcher must never panic on arbitrary text, however +// pathological, and must be deterministic. +// +// The two richer generator buckets (real, +// varied-structure source code and prose, both large enough to reach a +// dispatch arm) make each case noticeably more expensive than the old +// all-small-or-threshold-capped mix — a meaningful share of cases now +// actually run CodeAwareCompressor's tree-sitter parse or the Kompress +// path instead of bottoming out at `BelowByteThreshold` immediately. +// Case counts were cut from the earlier 2048/1024 (order of magnitude +// of `sse_framing.rs`'s parser fuzz tests) down to 40/20 after two +// larger settings both measured over the ~60s guidance with the richer +// generators in place: 128/64 measured ~84s combined, 64/32 measured +// ~60s combined (right at the boundary, not comfortably under it). +// 40/20 is what actually landed with margin. If a future +// change to these generators pushes wall time back over budget, the +// guidance is to cut case counts further, not shrink the payload +// ranges back down to threshold-capped sizes (that would silently +// regress dispatch reach). The no-panic property keeps the larger count +// since it's the property most likely to catch a real crash, the +// determinism property the smaller since each case dispatches twice. +// +// Kompress may be cache-cold on this machine (the HF cache is +// per-machine, not part of the repo) — both properties below must +// hold regardless: `kompress_or_noop` degrades to a deterministic +// NoOp when the model isn't cache-resident, which is a valid outcome +// for both "never panics" and "deterministic", not a test dependency +// on the model being loaded. + +proptest! { + #![proptest_config(ProptestConfig { + cases: 40, + // Give the shrinker room to minimize any panic it finds down + // to a small repro instead of giving up early. + max_shrink_iters: 1024, + ..ProptestConfig::default() + })] + + /// Property 1: for arbitrary (including pathological) `String`s + /// embedded as a `tool_result` body, dispatching through the + /// public `compress_anthropic_live_zone` entry point must never + /// panic. + #[test] + fn dispatch_no_panic_on_arbitrary_text(text in pathological_text()) { + let (body, _) = body_with_tool_result(&text); + // `dispatch` itself panics (via `.expect`) only on a + // dispatcher `Err`, which a well-formed JSON body constructed + // above can't produce. Reaching the end of this closure + // without unwinding IS the property. + let _ = dispatch(&body); + } +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 20, + ..ProptestConfig::default() + })] + + /// Property 2: determinism. The dispatcher's only process-global + /// state is the `HEADROOM_LIVE_ZONE_DISABLE_ARMS` kill-switch set + /// (unset in this file, and latched once regardless), so the same + /// input bytes must always produce the same output bytes AND the + /// same manifest. `CompressionManifest` / `BlockAction` are + /// `Debug`-only (no `PartialEq` — they're observability types, not + /// meant for equality comparisons in production code), so this + /// compares their `Debug` renderings as a structural-equality + /// proxy, the standard workaround for that situation. + #[test] + fn dispatch_deterministic_same_bytes(text in pathological_text()) { + let (body, _) = body_with_tool_result(&text); + + let out1 = dispatch(&body); + let out2 = dispatch(&body); + + let (bytes1, manifest1) = match &out1 { + LiveZoneOutcome::NoChange { manifest } => (body.clone(), format!("{manifest:?}")), + LiveZoneOutcome::Modified { new_body, manifest } => { + (new_body.get().as_bytes().to_vec(), format!("{manifest:?}")) + } + }; + let (bytes2, manifest2) = match &out2 { + LiveZoneOutcome::NoChange { manifest } => (body.clone(), format!("{manifest:?}")), + LiveZoneOutcome::Modified { new_body, manifest } => { + (new_body.get().as_bytes().to_vec(), format!("{manifest:?}")) + } + }; + + prop_assert_eq!( + &bytes1, &bytes2, + "same input bytes must yield the same output bytes (bytes in -> bytes out)" + ); + prop_assert_eq!( + manifest1, manifest2, + "same input bytes must yield the same manifest" + ); + } +} + +// ─── Part 1, property 3: byte fidelity around the SourceCode arm ────── + +#[test] +fn byte_fidelity_outside_compressed_source_block() { + // Same central invariant as `live_zone_dispatch.rs`'s + // `byte_fidelity_outside_compressed_block` (the B3 SmartCrusher + // pin), cloned onto the PR-B4 SourceCode/CodeAwareCompressor arm: + // bytes OUTSIDE the rewritten block must hash byte-identical to + // the input, regardless of which compressor did the rewriting. + let code = python_module_source(10); + assert!( + code.len() > 2048, + "fixture must clear the SourceCode byte threshold (2048); got {} bytes", + code.len() + ); + + let (body_in, content_range) = body_with_tool_result(&code); + let (block_start, block_end) = content_range; + + let out = dispatch(&body_in); + let (new_body, strategy) = match &out { + LiveZoneOutcome::Modified { new_body, manifest } => { + let action = manifest + .block_outcomes + .iter() + .find(|b| b.block_type == "tool_result") + .expect("tool_result block present in manifest") + .action + .clone(); + let strategy = match action { + BlockAction::Compressed { strategy, .. } => strategy, + other => panic!( + "expected Compressed action for a 10-function Python module, got {other:?}" + ), + }; + (new_body.get().as_bytes().to_vec(), strategy) + } + LiveZoneOutcome::NoChange { manifest } => panic!( + "expected CodeAwareCompressor to shrink a 10-function Python module; \ + got NoChange. manifest: {manifest:?}" + ), + }; + assert_eq!( + strategy, "code_compressor", + "expected code_compressor dispatch for SourceCode content" + ); + + // Prefix bytes (before the content slot) must be byte-identical. + let in_prefix = &body_in[..block_start]; + let out_prefix = &new_body[..block_start]; + assert_eq!( + sha256(in_prefix), + sha256(out_prefix), + "prefix bytes outside the compressed block must be byte-equal" + ); + + // Suffix length will differ by the compression delta, so locate + // the suffix in the output by length: it's the trailing + // (in.len() - block_end) bytes. + let in_suffix_len = body_in.len() - block_end; + let in_suffix = &body_in[block_end..]; + let out_suffix = &new_body[new_body.len() - in_suffix_len..]; + assert_eq!( + sha256(in_suffix), + sha256(out_suffix), + "suffix bytes outside the compressed block must be byte-equal" + ); + + // Output must still be valid JSON with the untouched top-level + // fields intact. + let parsed: Value = serde_json::from_slice(&new_body).expect("output is valid JSON"); + assert_eq!(parsed["model"], "claude-sonnet-4-6"); + assert_eq!(parsed["system"], "you are a helpful assistant"); +} + +/// The same byte-fidelity invariant for the PlainText/Kompress arm. +/// +/// Kompress is cache-only: on a host with no cached model the arm falls +/// open to a no-op by design, so a test that merely tolerates both +/// outcomes asserts nothing on a cold host. This one establishes ground +/// truth FIRST — asking the loader directly whether the model is +/// cache-resident — and then requires the dispatcher to agree: +/// available means the arm MUST compress and preserve the bytes outside +/// the block; unavailable means it MUST be a no-op. Either way the +/// assertion is real, and a regression that silently disabled the arm +/// on a warm host fails here rather than passing as "cold cache". +#[test] +fn plain_text_arm_preserves_bytes_outside_the_compressed_block() { + let prose = common::plain_prose(8_000); + assert!( + prose.len() > 5_120, + "fixture must clear the PlainText byte threshold (5120); got {} bytes", + prose.len() + ); + let (body, (start, end)) = body_with_tool_result(&prose); + let prefix_hash = sha256(&body[..start]); + let suffix_hash = sha256(&body[end..]); + + let kompress_available = common::kompress_available(); + + match dispatch(&body) { + LiveZoneOutcome::NoChange { manifest } => { + assert!( + !kompress_available, + "Kompress IS cache-resident on this host, so the PlainText arm should have compressed a {}-byte prose block above the 5120-byte threshold -- a NoChange here means the arm is unwired or silently disabled, which is exactly the regression this test exists to catch. manifest: {manifest:?}", + prose.len() + ); + } + LiveZoneOutcome::Modified { new_body, .. } => { + assert!( + kompress_available, + "the PlainText arm compressed a block while the loader reports no cached model" + ); + let new_bytes = new_body.get().as_bytes().to_vec(); + let suffix_len = body.len() - end; + assert!( + new_bytes.len() > start + suffix_len, + "rewritten body is too short to contain the untouched prefix and suffix" + ); + let new_end = new_bytes.len() - suffix_len; + assert_eq!( + sha256(&new_bytes[..start]), + prefix_hash, + "PlainText arm rewrote bytes BEFORE the compressed block" + ); + assert_eq!( + sha256(&new_bytes[new_end..]), + suffix_hash, + "PlainText arm rewrote bytes AFTER the compressed block" + ); + assert!( + new_bytes.len() < body.len(), + "arm reported Modified without shrinking the body" + ); + } + } +} + +// ─── Part 1, instrumentation: measured dispatch-reach fractions ─────── +// +// A review quantified (from pinned +// proptest 1.11.0 source and the earlier `pathological_text()` +// weights) that ~4.5% of generated cases reached ANY dispatch arm and +// 0% ever reached SourceCode. This section re-measures the same +// quantity against the CURRENT generator empirically, rather than just +// asserting the fix worked. + +/// Sample count for the reach-fraction measurement below. Classification +/// runs through `detect_content_type` only — no compressor is ever +/// invoked — so this stays well under a second even at this size. +const REACH_SAMPLE_COUNT: u32 = 5_000; + +/// Measures, over `REACH_SAMPLE_COUNT` samples of `pathological_text()`, +/// what fraction actually clear their content type's byte threshold and +/// would reach a `dispatch_compressor` arm (as opposed to being filtered +/// out at `BelowByteThreshold` before any compressor runs), broken down +/// by content type. Classification uses the SAME public function the +/// dispatcher itself calls (`headroom_core::transforms::detect_content_type`, +/// invoked from `live_zone.rs` right before `compress_one_block`'s +/// threshold gate — see that call site), on the same raw content-text +/// string this file's own `body_with_tool_result` embeds, so this +/// measures the real thing rather than a guess. +/// +/// Uses a fixed-seed `TestRunner` (not the `proptest!` macro) so the +/// sample is reproducible run to run — this test's job is to produce a +/// stable, reportable number, not to hunt for new failing cases (that's +/// what the two properties above are for). +/// +/// Floors are set well below the closed-form expectation from the +/// current bucket weights (SourceCode ~20%, PlainText ~24%, overall +/// ~44% — from each bucket's weight times its probability of clearing +/// its threshold) so ordinary sampling noise +/// at N=5000 can't flake this, while a future regression that collapses +/// a bucket back to threshold-capped output (the original +/// failure mode this instrumentation exists to catch) fails loudly +/// instead of silently. +#[test] +fn dispatch_reach_fractions_meet_floor() { + let mut runner = TestRunner::new_with_rng( + ProptestConfig::default(), + TestRng::from_seed(RngAlgorithm::ChaCha, &[0x5A; 32]), + ); + let strategy = pathological_text(); + + let mut reached_total = 0u32; + let mut reached_source_code = 0u32; + let mut reached_plain_text = 0u32; + let mut sampled = 0u32; + + for _ in 0..REACH_SAMPLE_COUNT { + let tree = strategy + .new_tree(&mut runner) + .expect("pathological_text() strategy never rejects a case"); + let text = tree.current(); + if text.is_empty() { + // `dispatch_compressor` special-cases empty content to an + // unconditional NoOp before the byte-threshold gate even + // runs (see live_zone.rs) — "reach" is undefined for it. + // Vanishingly rare from these generators; excluded from + // both numerator and denominator rather than counted + // either way. + continue; + } + sampled += 1; + let detected = detect_content_type(&text); + let reached = text.len() >= threshold_for(detected.content_type); + if reached { + reached_total += 1; + match detected.content_type { + ContentType::SourceCode => reached_source_code += 1, + ContentType::PlainText => reached_plain_text += 1, + _ => {} + } + } + } + + let denom = f64::from(sampled.max(1)); + let overall_frac = f64::from(reached_total) / denom; + let source_frac = f64::from(reached_source_code) / denom; + let plain_frac = f64::from(reached_plain_text) / denom; + + println!( + "dispatch-reach over {sampled} sampled cases (of {REACH_SAMPLE_COUNT} drawn): \ + overall {overall_frac:.4} ({reached_total}), \ + source_code {source_frac:.4} ({reached_source_code}), \ + plain_text {plain_frac:.4} ({reached_plain_text})" + ); + + assert!( + source_frac > 0.10, + "SourceCode dispatch-reach fraction regressed: {source_frac:.4} (want > 0.10)" + ); + assert!( + plain_frac > 0.10, + "PlainText dispatch-reach fraction regressed: {plain_frac:.4} (want > 0.10)" + ); + assert!( + overall_frac > 0.25, + "overall dispatch-reach fraction regressed: {overall_frac:.4} (want > 0.25)" + ); +} diff --git a/crates/headroom-core/tests/live_zone_disable_arms.rs b/crates/headroom-core/tests/live_zone_disable_arms.rs new file mode 100644 index 0000000000..c9f137e4b6 --- /dev/null +++ b/crates/headroom-core/tests/live_zone_disable_arms.rs @@ -0,0 +1,140 @@ +//! Env-var kill-switch for the live-zone dispatch arms — dedicated +//! integration test file. +//! +//! `disabled_arms` (`crates/headroom-core/src/transforms/live_zone.rs`) +//! reads `HEADROOM_LIVE_ZONE_DISABLE_ARMS` on first dispatch and latches +//! the parsed set behind a process-global `OnceLock` (the determinism +//! invariant — a run's arm-disable set cannot change mid-flight). +//! Environment variables are process-global too, so setting the variable +//! must happen in a test process that does nothing else: any other test +//! in the same binary that dispatched first would freeze the `OnceLock` +//! against whatever it saw at that point. That is why this file holds +//! exactly ONE `#[test]` fn — a dedicated file is a dedicated test +//! binary, so no other test can race the initialization. +//! +//! Do NOT add more tests to this file. +//! +//! **On `set_var`.** `std::env::set_var` is unsound in a multi-threaded +//! process (it races any concurrent reader of the environment, including +//! ones inside libc) and becomes `unsafe` in edition 2024. It is used +//! here because the switch's input genuinely *is* the environment and +//! this binary is single-threaded at the point of the call. The parsing +//! contract itself — aliases, trimming, unknown tokens — is covered +//! without any environment mutation by +//! `live_zone_disable_arms_parsing.rs` against the pure +//! `parse_disabled_arms`. Threading an explicit config value into the +//! dispatcher would remove the need for this file entirely; see the PR +//! description. + +mod common; + +use common::{ + body_with_tool_result, dispatch, json_array_of_dicts, kompress_available, plain_prose, + python_module_source, tool_result_action, +}; +use headroom_core::transforms::{BlockAction, LiveZoneOutcome}; + +/// Assert the single `tool_result` block was left uncompressed. +fn assert_no_compression(label: &str, body: &[u8]) { + let out = dispatch(body); + let manifest = match &out { + LiveZoneOutcome::NoChange { manifest } => manifest, + LiveZoneOutcome::Modified { manifest, .. } => { + panic!( + "disabled {label} arm must not rewrite bytes; got Modified. manifest: {manifest:?}" + ) + } + }; + let action = tool_result_action(manifest); + assert!( + matches!(action, BlockAction::NoCompressionApplied { .. }), + "disabled {label} arm must yield NoCompressionApplied, got {action:?}" + ); +} + +#[test] +fn disabled_arms_route_to_no_op_others_unaffected() { + // `source_code, plain_text, json_array, bogus_type` — the internal + // spaces exercise the trim path and `bogus_type` the unknown-token + // branch (logged and ignored, never a panic). + std::env::set_var( + "HEADROOM_LIVE_ZONE_DISABLE_ARMS", + "source_code, plain_text, json_array, bogus_type", + ); + + // (a) SourceCode disabled: a >2048-byte Python tool_result must not + // reach the CodeAwareCompressor. Deterministic on every host — the + // code arm has no model-cache dependency. + let code = python_module_source(10); + assert!( + code.len() > 2048, + "fixture must clear the SourceCode byte threshold (2048); got {} bytes", + code.len() + ); + assert_no_compression("SourceCode", &body_with_tool_result(&code).0); + + // (b) JsonArray disabled: SmartCrusher must not fire on a shape it + // would otherwise compress on any host. This is the case that + // discriminates the kill switch unconditionally — unlike (c), it + // cannot be satisfied by an absent model — and it also pins that the + // switch is generic across arms rather than special-cased to the two + // this PR wires. + let payload = json_array_of_dicts(200); + assert!( + payload.len() > 512, + "fixture must clear the JsonArray byte threshold (512); got {} bytes", + payload.len() + ); + assert_no_compression("JsonArray", &body_with_tool_result(&payload).0); + + // (c) PlainText disabled: a >5120-byte prose tool_result must not + // reach Kompress. Two vacuity traps, not one: + // + // 1. The fixture MUST clear THRESHOLD_PLAIN_TEXT (5120) or the + // byte-threshold gate short-circuits before dispatch and the + // assertion passes regardless of the switch. + // 2. On a cold HuggingFace cache the enabled arm's fall-open path + // returns the identical no-op, so this cannot discriminate there. + // `kompress_available()` says which case actually ran; (b) above + // carries the unconditional coverage. + let prose = plain_prose(5_200); + assert!( + prose.len() > 5120, + "fixture must clear the PlainText byte threshold (5120); got {} bytes", + prose.len() + ); + assert_no_compression("PlainText", &body_with_tool_result(&prose).0); + if !kompress_available() { + eprintln!( + "NOTE: cold model cache — the PlainText case passed but could not discriminate \ + the kill switch from an absent model this run. Unconditional coverage comes \ + from the JsonArray case above." + ); + } + + // (d) An arm NOT named in the list still compresses: build output + // routes to LogCompressor as usual, proving the switch is selective + // rather than a global off. + let logs = "\ +ERROR src/main.rs:42 connection refused +WARN src/pool.rs:17 retrying in 250ms +ERROR src/main.rs:42 connection refused +WARN src/pool.rs:17 retrying in 500ms +" + .repeat(40); + let out = dispatch(&body_with_tool_result(&logs).0); + let manifest = match &out { + LiveZoneOutcome::Modified { manifest, .. } => manifest, + LiveZoneOutcome::NoChange { manifest } => panic!( + "an arm absent from the disable list must be unaffected; got NoChange. \ + manifest: {manifest:?}" + ), + }; + match tool_result_action(manifest) { + BlockAction::Compressed { strategy, .. } => assert_eq!( + strategy, "log_compressor", + "expected LogCompressor dispatch, unaffected by the disabled arms" + ), + other => panic!("expected BlockAction::Compressed via log_compressor, got {other:?}"), + } +} diff --git a/crates/headroom-core/tests/live_zone_disable_arms_parsing.rs b/crates/headroom-core/tests/live_zone_disable_arms_parsing.rs new file mode 100644 index 0000000000..f972beb1ce --- /dev/null +++ b/crates/headroom-core/tests/live_zone_disable_arms_parsing.rs @@ -0,0 +1,64 @@ +//! Parsing contract of the `HEADROOM_LIVE_ZONE_DISABLE_ARMS` kill +//! switch, tested against the pure `parse_disabled_arms` rather than +//! through the process environment. +//! +//! The end-to-end wiring (env var → latched set → dispatcher no-op) +//! lives in `live_zone_disable_arms.rs`, which needs `std::env::set_var` +//! and therefore a dedicated single-test binary. Everything about how a +//! string becomes a set of arms is checkable here instead: no global +//! state, no isolation requirement, and these cases can run alongside +//! anything else. + +use headroom_core::transforms::live_zone::parse_disabled_arms; +use headroom_core::transforms::ContentType; + +#[test] +fn parses_both_spellings_and_trims_whitespace() { + let parsed = parse_disabled_arms("source_code, plain_text"); + assert!(parsed.contains(&ContentType::SourceCode)); + assert!(parsed.contains(&ContentType::PlainText)); + assert_eq!(parsed.len(), 2); + + // `as_str()` tags parse to the same variants as the natural names. + let tags = parse_disabled_arms("source_code,text"); + assert_eq!(parsed, tags, "both spellings must yield the same set"); +} + +#[test] +fn every_variant_can_be_named() { + for content_type in ContentType::ALL { + for spelling in [content_type.as_str(), content_type.natural_name()] { + let parsed = parse_disabled_arms(spelling); + assert!( + parsed.contains(&content_type), + "{spelling:?} must disable {content_type:?}" + ); + } + } +} + +#[test] +fn unknown_and_blank_tokens_are_ignored_not_fatal() { + // A typo in an operator's rollback switch must not take the proxy + // down, and must not silently disable something else either. + let parsed = parse_disabled_arms("bogus_type, , source_code,, "); + assert_eq!( + parsed, + std::iter::once(ContentType::SourceCode).collect(), + "unknown and empty tokens drop out; valid ones survive" + ); +} + +#[test] +fn empty_input_disables_nothing() { + assert!(parse_disabled_arms("").is_empty()); + assert!(parse_disabled_arms(" ").is_empty()); + assert!(parse_disabled_arms(",,,").is_empty()); +} + +#[test] +fn repeated_tokens_collapse() { + let parsed = parse_disabled_arms("plain_text,text,plain_text"); + assert_eq!(parsed.len(), 1); + assert!(parsed.contains(&ContentType::PlainText)); +} diff --git a/crates/headroom-core/tests/live_zone_dispatch.rs b/crates/headroom-core/tests/live_zone_dispatch.rs index c8f55cf009..3f5c84b013 100644 --- a/crates/headroom-core/tests/live_zone_dispatch.rs +++ b/crates/headroom-core/tests/live_zone_dispatch.rs @@ -6,77 +6,17 @@ //! - Build/log output → LogCompressor //! - Search-result tool_results → SearchCompressor //! - Git diff tool_results → DiffCompressor -//! - Source code → no-op (Rust port pending) +//! - Source code → CodeAwareCompressor //! - Unknown / image / html → no-op //! //! Plus the cache-safety invariant: bytes outside the rewritten //! block are byte-identical to the input (SHA-256 prefix + suffix). -use headroom_core::transforms::live_zone::DEFAULT_MODEL; -use headroom_core::transforms::{ - compress_anthropic_live_zone, AuthMode, BlockAction, LiveZoneOutcome, -}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; - -fn body_of(value: Value) -> Vec { - serde_json::to_vec(&value).unwrap() -} - -fn dispatch(body: &[u8]) -> LiveZoneOutcome { - compress_anthropic_live_zone(body, 0, AuthMode::Payg, DEFAULT_MODEL) - .expect("dispatcher returns Ok on valid bodies") -} - -/// Find the byte range of the FIRST occurrence of `needle` inside -/// `haystack`. Used by the byte-fidelity test below to identify the -/// JSON-encoded tool_result.content slot we expect the dispatcher to -/// rewrite. Returns `(start, end)` half-open. -fn find_byte_range(haystack: &[u8], needle: &[u8]) -> (usize, usize) { - let pos = haystack - .windows(needle.len()) - .position(|w| w == needle) - .unwrap_or_else(|| { - panic!( - "needle of {} bytes not found in haystack of {} bytes", - needle.len(), - haystack.len() - ) - }); - (pos, pos + needle.len()) -} +mod common; -fn sha256(bytes: &[u8]) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(bytes); - h.finalize().into() -} - -/// Build a body with one user message containing one `tool_result` -/// whose `content` is `text`. Returns the full body and the byte -/// range of the JSON-encoded `content` slot (including the surrounding -/// quotes) within that body — useful for byte-fidelity assertions. -fn body_with_tool_result(text: &str) -> (Vec, (usize, usize)) { - let body = body_of(json!({ - "model": "claude-sonnet-4-6", - "max_tokens": 64, - "system": "you are a helpful assistant", - "messages": [{ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "toolu_dispatch_test", - "content": text, - }], - }], - })); - // The JSON-encoded `content` slot is exactly `serde_json::to_vec(&text)`, - // since text is shorter than the whole body and serde uses the same - // encoding for the embedded string. - let needle = serde_json::to_vec(&text).unwrap(); - let range = find_byte_range(&body, &needle); - (body, range) -} +use common::{body_with_tool_result, dispatch, kompress_available, python_module_source, sha256}; +use headroom_core::transforms::{BlockAction, LiveZoneOutcome}; +use serde_json::{json, Value}; // ─── Routing tests ───────────────────────────────────────────────────── @@ -262,29 +202,83 @@ fn diff_tool_result_routes_to_diff_compressor() { } #[test] -fn source_code_tool_result_routes_to_no_op() { - // Detector classifies this as SourceCode. PR-B3 routes it to - // no-op (Rust code-compressor port pending). Pin the contract - // so a future "wire it up" PR can flip this assertion. - let code = " -fn main() { - let x: i32 = 42; - let y = x * 2; - println!(\"{}\", y); - if x > 0 { - println!(\"positive\"); - } else { - println!(\"non-positive\"); +fn source_code_tool_result_routes_to_code_compressor() { + // Detector classifies this as SourceCode. PR-B4 wires the arm up to + // the tree-sitter-backed CodeAwareCompressor. This flips the PR-B3 + // pin ("a future 'wire it up' PR can flip this assertion"). + let code = python_module_source(10); + assert!( + code.len() > 2048, + "fixture must clear the SourceCode byte threshold (2048); got {} bytes", + code.len() + ); + + let (body, _) = body_with_tool_result(&code); + let out = dispatch(&body); + let manifest = match &out { + LiveZoneOutcome::Modified { manifest, .. } => manifest, + LiveZoneOutcome::NoChange { manifest } => panic!( + "expected CodeAwareCompressor to shrink a 10-function Python module; got NoChange. manifest: {manifest:?}" + ), + }; + let action = manifest + .block_outcomes + .iter() + .find(|b| b.block_type == "tool_result") + .expect("tool_result block present") + .action + .clone(); + match action { + BlockAction::Compressed { + strategy, + original_tokens, + compressed_tokens, + .. + } => { + assert_eq!( + strategy, "code_compressor", + "expected code_compressor dispatch" + ); + assert!( + compressed_tokens < original_tokens, + "tokenizer-validated gate (PR-B4) must accept only token-shrinking output \ + ({compressed_tokens} < {original_tokens})" + ); + } + other => panic!("expected BlockAction::Compressed, got {other:?}"), } } -" - .repeat(20); - let (body, _) = body_with_tool_result(&code); + +#[test] +fn tiny_source_code_below_threshold_no_op() { + // Detector classifies this as SourceCode, but it's well under the + // 2048-byte SourceCode threshold, so the dispatcher must not even + // spin up the CodeAwareCompressor. + let code = "\ +import os +from typing import Any + + +def add(a: int, b: int) -> int: + \"\"\"Add two integers.\"\"\" + return a + b + + +if __name__ == \"__main__\": + print(add(1, 2)) +"; + assert!( + code.len() < 2048, + "fixture must stay below the SourceCode byte threshold (2048); got {} bytes", + code.len() + ); + + let (body, _) = body_with_tool_result(code); let out = dispatch(&body); let manifest = match &out { LiveZoneOutcome::NoChange { manifest } => manifest, LiveZoneOutcome::Modified { manifest, .. } => { - panic!("PR-B3 must NOT compress SourceCode (Rust port pending). manifest: {manifest:?}") + panic!("tiny source-code block must not be compressed. manifest: {manifest:?}") } }; let action = manifest @@ -295,26 +289,119 @@ fn main() { .action .clone(); match action { - BlockAction::NoCompressionApplied { content_type } => { - // Source-code-shaped content above the SourceCode byte - // threshold (2 KiB) but below any active compressor: - // SmartCrusher / log / search / diff don't apply, and - // the Rust code-compressor port is not yet wired. - assert!( - content_type == "source_code" || content_type == "text", - "unexpected content_type tag: {content_type}" - ); + BlockAction::BelowByteThreshold { + content_type, + threshold_bytes, + .. + } => { + assert_eq!(threshold_bytes, 2048, "expected the SourceCode threshold"); + assert_eq!(content_type, "source_code", "unexpected content_type tag"); } - BlockAction::BelowByteThreshold { content_type, .. } => { - // Detector may classify code-with-prose as PlainText - // (5 KiB threshold) — for ~2.6 KiB of mixed code/prose - // that still routes to no-op for B4. Pin the tag. - assert!( - content_type == "text" || content_type == "source_code", - "unexpected content_type tag: {content_type}" - ); + other => panic!("expected BelowByteThreshold, got {other:?}"), + } +} + +#[test] +fn plain_text_below_threshold_no_op() { + // Plain prose, well under the 5120-byte PlainText threshold, so the + // dispatcher must not even attempt Kompress. + let prose = "The quarterly report highlighted steady growth across every \ + region, with the operations team noting improved throughput on the \ + warehouse floor and customer support tickets trending downward for \ + the third month running. Leadership expects the trend to continue \ + into next quarter, barring any supply chain disruptions."; + assert!( + prose.len() < 5120, + "fixture must stay below the PlainText byte threshold (5120); got {} bytes", + prose.len() + ); + + let (body, _) = body_with_tool_result(prose); + let out = dispatch(&body); + let manifest = match &out { + LiveZoneOutcome::NoChange { manifest } => manifest, + LiveZoneOutcome::Modified { manifest, .. } => { + panic!("sub-threshold plain text must not be compressed. manifest: {manifest:?}") + } + }; + let action = manifest + .block_outcomes + .iter() + .find(|b| b.block_type == "tool_result") + .expect("tool_result block present") + .action + .clone(); + match action { + BlockAction::BelowByteThreshold { + content_type, + threshold_bytes, + .. + } => { + assert_eq!(threshold_bytes, 5120, "expected the PlainText threshold"); + assert_eq!(content_type, "text", "unexpected content_type tag"); + } + other => panic!("expected BelowByteThreshold, got {other:?}"), + } +} + +#[test] +fn plain_text_routes_to_kompress_when_model_cached() { + // RUNTIME-SKIP: ask the loader itself whether the model is + // cache-resident, rather than re-deriving cache paths here. A + // hand-rolled probe has to be kept byte-compatible with + // `Kompress::from_cache`'s own root and artifact resolution, and + // when it drifts the skip becomes a lie — the test silently stops + // running on hosts where production would load the model. + if !kompress_available() { + eprintln!( + "SKIP: kompress model/tokenizer not cache-resident; run `python scripts/record_kompress_trace.py` first" + ); + return; + } + + // Repetitive news-article-like prose: > 350 words so Kompress's + // chunk_words=350 chunking actually engages, and > 5120 bytes to + // clear the PlainText byte threshold. + let mut article = String::new(); + for i in 0..60 { + article.push_str(&format!( + "City officials announced today that the downtown revitalization \ + project will proceed as planned despite budget concerns raised \ + during round {i} of public comment. " + )); + } + assert!( + article.split_whitespace().count() > 350, + "fixture must exceed 350 words so Kompress chunking engages; got {} words", + article.split_whitespace().count() + ); + assert!( + article.len() > 5120, + "fixture must clear the PlainText byte threshold (5120); got {} bytes", + article.len() + ); + + let (body, _) = body_with_tool_result(&article); + let out = dispatch(&body); + let manifest = match &out { + LiveZoneOutcome::Modified { manifest, .. } => manifest, + LiveZoneOutcome::NoChange { manifest } => panic!( + "expected Kompress to compress a {}-word repetitive article; got NoChange. manifest: {manifest:?}", + article.split_whitespace().count() + ), + }; + let action = manifest + .block_outcomes + .iter() + .find(|b| b.block_type == "tool_result") + .expect("tool_result block present") + .action + .clone(); + match action { + BlockAction::Compressed { strategy, .. } => { + assert_eq!(strategy, "kompress", "expected kompress dispatch"); } - other => panic!("expected NoCompressionApplied or BelowByteThreshold, got {other:?}"), + other => panic!("expected BlockAction::Compressed via kompress, got {other:?}"), } } diff --git a/crates/headroom-core/tests/live_zone_kompress_absent.rs b/crates/headroom-core/tests/live_zone_kompress_absent.rs new file mode 100644 index 0000000000..0235efd81b --- /dev/null +++ b/crates/headroom-core/tests/live_zone_kompress_absent.rs @@ -0,0 +1,80 @@ +//! Model-absent NoOp — dedicated integration test file. +//! +//! The Kompress slot (`crates/headroom-core/src/transforms/live_zone.rs`) +//! latches process-globally: the first PlainText dispatch starts its +//! background initialization, whose cache lookup reads `HF_HUB_CACHE` / +//! `HF_HOME` / `HOME` / `USERPROFILE`. Environment variables are +//! process-global too, so forcing those four to a cold, empty cache dir +//! must happen in a test process that does nothing else — any other test +//! in the same binary that dispatched PlainText content first would +//! settle the slot against the *ambient* environment instead. That is +//! why this file holds exactly ONE `#[test]` fn. +//! +//! Do NOT add more tests to this file, and do NOT move this test into +//! `live_zone_dispatch.rs` (see +//! `plain_text_routes_to_kompress_when_model_cached` there, which relies +//! on the loader observing the *ambient* cache). +//! +//! On `set_var`: see the note in `live_zone_disable_arms.rs`. The same +//! caveat applies, for the same reason — the input under test is the +//! environment. An injectable cache root on `Kompress::from_cache` would +//! remove the need for this file; see the PR description. + +mod common; + +use common::{body_with_tool_result, dispatch, plain_prose, tool_result_action}; +use headroom_core::transforms::{BlockAction, LiveZoneOutcome}; + +#[test] +fn plain_text_model_absent_is_deterministic_no_op() { + // Force every cache root the loader consults to a fresh, empty temp + // dir so the lookup deterministically misses, rather than picking up + // whatever happens to be in the real user cache on this machine. + let cold_dir = tempfile::tempdir().expect("create fresh temp dir for cold model cache"); + let cold_path = cold_dir.path().to_str().expect("temp dir path is UTF-8"); + for var in ["HF_HUB_CACHE", "HF_HOME", "HOME", "USERPROFILE"] { + std::env::set_var(var, cold_path); + } + + // > 5120 bytes so the PlainText byte threshold is cleared and the + // dispatcher actually attempts the arm rather than short-circuiting + // at `BelowByteThreshold`. + let prose = plain_prose(5_200); + assert!( + prose.len() > 5120, + "fixture must clear the PlainText byte threshold (5120); got {} bytes", + prose.len() + ); + + let body = body_with_tool_result(&prose).0; + let assert_no_op = |label: &str| { + let out = dispatch(&body); + let manifest = match &out { + LiveZoneOutcome::NoChange { manifest } => manifest, + LiveZoneOutcome::Modified { manifest, .. } => panic!( + "cache-cold Kompress must not rewrite bytes ({label}); got Modified. \ + manifest: {manifest:?}" + ), + }; + let action = tool_result_action(manifest); + assert!( + matches!(action, BlockAction::NoCompressionApplied { .. }), + "cache-cold Kompress must degrade to a deterministic NoOp ({label}), \ + not an error: {action:?}" + ); + }; + + // First dispatch: a NoOp by the non-blocking contract — this call is + // what starts the slot's background initialization. + assert_no_op("first dispatch, slot unsettled"); + + // Settle the slot off the request path and pin WHY it is empty: the + // loader found nothing under the forced-cold roots. + assert!( + !headroom_core::transforms::live_zone::warm_live_zone_compressors(), + "the loader reported a model ready under a forced-cold cache root" + ); + + // A post-settle dispatch is the same deterministic NoOp. + assert_no_op("post-settle dispatch"); +} diff --git a/crates/headroom-core/tests/live_zone_kompress_async_init.rs b/crates/headroom-core/tests/live_zone_kompress_async_init.rs new file mode 100644 index 0000000000..afc2fef5a1 --- /dev/null +++ b/crates/headroom-core/tests/live_zone_kompress_async_init.rs @@ -0,0 +1,116 @@ +//! Non-blocking Kompress initialization — dedicated integration test file. +//! +//! The Kompress slot in `live_zone.rs` is process-global and latches on +//! first use, so proving anything about its *virgin* state needs a test +//! process that does nothing else first — the same reason +//! `live_zone_disable_arms.rs` and `live_zone_kompress_absent.rs` are +//! single-test binaries. Do NOT add more tests to this file. +//! +//! Contract under test (#3227 review): activating the PlainText arm +//! because model artifacts happen to be cache-resident must not make the +//! first qualifying request pay — or wait on — the ~261 MB ONNX session +//! build. Dispatches that race a virgin slot return promptly as NoOp; +//! the expensive construction runs exactly once, off the request path. +//! +//! Two vacuity notes: +//! +//! 1. The timing assertion discriminates only on a warm HuggingFace +//! cache, where the synchronous build costs whole seconds; on a cold +//! cache the loader returns `None` quickly and even a blocking +//! implementation passes the bound. The exactly-once and NoOp +//! assertions hold on any host. +//! 2. The NoOp assertion cannot be raced into `Modified` by a straggler +//! thread observing a *completed* init: every thread dispatches +//! barrier-synchronized within microseconds of the CAS that starts +//! the initializer, and the build it would have to lose to costs +//! seconds (tokenizer load + 261 MB session commit). + +mod common; + +use std::sync::{Arc, Barrier}; +use std::time::{Duration, Instant}; + +use common::{body_with_tool_result, dispatch, plain_prose, tool_result_action}; +use headroom_core::transforms::live_zone; +use headroom_core::transforms::{BlockAction, LiveZoneOutcome}; + +#[test] +fn first_dispatch_never_blocks_on_model_init() { + // > 5120 bytes so the PlainText byte threshold is cleared and the + // dispatcher actually attempts the arm rather than short-circuiting + // at `BelowByteThreshold`. + let prose = plain_prose(5_200); + assert!( + prose.len() > 5120, + "fixture must clear the PlainText byte threshold (5120); got {} bytes", + prose.len() + ); + let (body, _) = body_with_tool_result(&prose); + + // Phase 1 — virgin slot, concurrent first requests. Every dispatch + // must return promptly (never waiting on a model build another + // thread started) and must leave the body untouched. + let n_threads = 8; + let barrier = Arc::new(Barrier::new(n_threads)); + let handles: Vec<_> = (0..n_threads) + .map(|_| { + let body = body.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + let started = Instant::now(); + let out = dispatch(&body); + (started.elapsed(), out) + }) + }) + .collect(); + + for (i, handle) in handles.into_iter().enumerate() { + let (elapsed, out) = handle.join().expect("dispatch thread panicked"); + assert!( + elapsed < Duration::from_millis(500), + "thread {i}: dispatch must not wait on model initialization; took {elapsed:?}" + ); + assert!( + matches!(out, LiveZoneOutcome::NoChange { .. }), + "thread {i}: a dispatch racing a virgin model slot must be a NoOp \ + while initialization is in flight, not a rewrite" + ); + } + + // Phase 2 — however many threads raced the virgin slot, the + // expensive construction ran exactly once; a blocking warmup (the + // proxy's startup path) settles the slot and reports readiness. + let ready = live_zone::warm_live_zone_compressors(); + let expected_runs = usize::from(cfg!(feature = "ml")); + assert_eq!( + live_zone::kompress_init_runs(), + expected_runs, + "the model construction must run exactly once per process" + ); + + // Phase 3 — with the slot settled, the arm compresses iff the model + // actually loaded; readiness comes from the warmup itself, so this + // cannot silently skip on a host where production would compress. + if ready { + match dispatch(&body) { + LiveZoneOutcome::Modified { manifest, .. } => match tool_result_action(&manifest) { + BlockAction::Compressed { strategy, .. } => assert_eq!( + strategy, "kompress", + "post-warmup PlainText dispatch must compress via Kompress" + ), + other => panic!("expected Compressed via kompress, got {other:?}"), + }, + LiveZoneOutcome::NoChange { manifest } => panic!( + "warmup reported the model ready, so a settled dispatch must compress \ + this above-threshold prose block; manifest: {manifest:?}" + ), + } + } else { + eprintln!( + "NOTE: cold model cache (or a no-ml build) — the non-blocking and \ + exactly-once contracts above are covered; the post-warmup compression \ + check could not run on this host." + ); + } +} diff --git a/crates/headroom-core/tests/live_zone_thresholds.rs b/crates/headroom-core/tests/live_zone_thresholds.rs index 429db3f298..c735ed2ca9 100644 --- a/crates/headroom-core/tests/live_zone_thresholds.rs +++ b/crates/headroom-core/tests/live_zone_thresholds.rs @@ -1,8 +1,9 @@ //! PR-B4 byte-threshold gate — integration tests. //! //! The dispatcher must skip compression entirely for blocks whose -//! content is below the per-content-type byte threshold (1 KiB for -//! JSON arrays). PR-B4 spec, `REALIGNMENT/04-phase-B-live-zone.md`. +//! content is below the per-content-type byte threshold (512 B for +//! JSON arrays — asserted below). PR-B4 spec, +//! `REALIGNMENT/04-phase-B-live-zone.md`. use headroom_core::transforms::live_zone::DEFAULT_MODEL; use headroom_core::transforms::{ @@ -88,7 +89,7 @@ fn below_threshold_no_compression_attempted() { #[test] fn above_threshold_compression_attempted() { - // 10 KB of homogeneous JSON dicts — comfortably above the 1 KiB + // 10 KB of homogeneous JSON dicts — comfortably above the 512 B // JsonArray threshold and SmartCrusher's bread-and-butter shape, // so the dispatcher SHOULD route through `dispatch_compressor`. // Either `Compressed` (SmartCrusher shrunk it — the typical diff --git a/crates/headroom-proxy/Cargo.toml b/crates/headroom-proxy/Cargo.toml index f664c41294..f7e146a3e9 100644 --- a/crates/headroom-proxy/Cargo.toml +++ b/crates/headroom-proxy/Cargo.toml @@ -56,6 +56,14 @@ aws-smithy-runtime-api = { workspace = true } # transitively pulled in by `aws-smithy-*` (so this is not an # additional cold dep — promoting to a direct one for clarity). crc32fast = "1" +# Native savings stats: Bedrock InvokeModel EventStream chunks wrap +# their Anthropic SSE payloads in base64 (`{"bytes": ""}`); +# the usage-capture tee decodes them. Already in the tree via the +# HTTP stack (reqwest/axum/hyper-util/gcp_auth all pull base64 +# 0.22) — promoted to direct for the explicit use, so no new +# major-version bucket. (The AWS crates use `base64-simd`, a +# different crate — this is not that one.) +base64 = "0.22" # Phase D PR-D3: Prometheus metrics for Bedrock observability. The # `prometheus` crate's default-features pull in `protobuf`, which we # don't need (we serve text-format scrapes only), so we disable @@ -102,7 +110,7 @@ md-5 = "0.11" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } rustls-native-certs = "0.8" rustls-pki-types = { version = "1", features = ["std"] } -webpki-roots = "0.26" +webpki-roots = "1.0" [dev-dependencies] tower = { workspace = true, features = ["util"] } diff --git a/crates/headroom-proxy/src/bedrock/invoke.rs b/crates/headroom-proxy/src/bedrock/invoke.rs index 6f0030f2b7..00a35e490b 100644 --- a/crates/headroom-proxy/src/bedrock/invoke.rs +++ b/crates/headroom-proxy/src/bedrock/invoke.rs @@ -51,10 +51,12 @@ use url::Url; use crate::bedrock::envelope::BedrockEnvelope; use crate::bedrock::sigv4::{sign_request, SigningInputs}; +use crate::bedrock::CompressionStats; use crate::compression::{ compress_anthropic_request, Outcome as AnthropicOutcome, PassthroughReason, }; use crate::headers::filter_response_headers; +use crate::observability::{capture, ledger}; use crate::observability::{observe_bedrock_invoke_latency, record_bedrock_invoke}; use crate::proxy::AppState; use crate::upstream_path::append_segments; @@ -175,8 +177,17 @@ pub async fn handle_invoke( "bedrock invoke route received request" ); + // Savings ledger: constructed at handler ENTRY so every exit — + // including proxy-side rejections (bad action path, missing + // credentials, SigV4 failure) — produces exactly one record. A + // credentials outage must show in /stats as a failure spike, + // not as zero traffic. `forward_http` gives its 413 rejections + // the same treatment. + let mut pending_stats = + capture::start_pending(&state, ledger::provider::BEDROCK, &model_id, &request_id); + let is_anthropic = is_anthropic_model_id(&model_id); - let outbound_body: Bytes = if is_anthropic { + let (outbound_body, compression_stats): (Bytes, CompressionStats) = if is_anthropic { run_anthropic_compression(&body, &state, auth_mode, &request_id) } else { tracing::info!( @@ -186,8 +197,15 @@ pub async fn handle_invoke( reason = "non_anthropic_vendor", "bedrock invoke: skipping live-zone compression for non-anthropic vendor" ); - body.clone() + (body.clone(), CompressionStats::default()) }; + if let Some(p) = pending_stats.as_mut() { + p.set_compression( + compression_stats.tokens_before, + compression_stats.tokens_after, + compression_stats.transforms, + ); + } // Resolve the Bedrock action from the inbound path so `/converse` // forwards to the upstream Converse endpoint instead of `/invoke`. @@ -202,6 +220,7 @@ pub async fn handle_invoke( path = %uri.path(), "bedrock invoke: unrecognized action path" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::BAD_REQUEST, "bedrock_invoke_action_invalid", @@ -235,6 +254,7 @@ pub async fn handle_invoke( error = %msg, "bedrock invoke: failed to construct upstream URL" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::INTERNAL_SERVER_ERROR, "bedrock_endpoint_invalid", @@ -253,6 +273,7 @@ pub async fn handle_invoke( model_id = %model_id, "bedrock invoke: refusing to forward without AWS credentials" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::INTERNAL_SERVER_ERROR, "bedrock_credentials_missing", @@ -288,6 +309,7 @@ pub async fn handle_invoke( error = %e, "bedrock invoke: SigV4 signing failed; refusing to forward" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::INTERNAL_SERVER_ERROR, "bedrock_sigv4_failed", @@ -329,6 +351,7 @@ pub async fn handle_invoke( error = %e, "bedrock invoke: invalid HTTP method" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::BAD_REQUEST, "bedrock_invalid_method", @@ -348,6 +371,9 @@ pub async fn handle_invoke( let upstream_resp = match upstream_resp { Ok(r) => r, Err(e) => { + if let Some(p) = pending_stats.take() { + p.finalize_failed(); + } tracing::warn!( event = "bedrock_upstream_error", request_id = %request_id, @@ -365,6 +391,11 @@ pub async fn handle_invoke( let status = StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + if !status.is_success() { + if let Some(p) = pending_stats.as_mut() { + p.mark_failed(); + } + } let resp_headers = filter_response_headers(upstream_resp.headers()); tracing::info!( @@ -376,10 +407,23 @@ pub async fn handle_invoke( "bedrock invoke: response forwarded" ); - // Stream the response body back without buffering. - let stream = upstream_resp - .bytes_stream() - .map(|r| r.map_err(std::io::Error::other)); + // Stream the response body back without buffering. When the + // savings ledger is recording, tee the bytes into a bounded + // capture task that parses the sync response's usage block + // (InvokeModel snake_case / Converse camelCase) at body end — + // best-effort, never blocking the client byte path. + let capture_tx = pending_stats + .take() + .map(|p| capture::spawn_json_usage_capture(capture::ResponseShape::Bedrock, p)); + let stream = upstream_resp.bytes_stream().map(move |r| match r { + Ok(b) => { + if let Some(tx) = &capture_tx { + let _ = tx.try_send(b.clone()); + } + Ok(b) + } + Err(e) => Err(std::io::Error::other(e)), + }); let body_out = Body::from_stream(stream); let mut builder = Response::builder().status(status); @@ -417,7 +461,7 @@ fn run_anthropic_compression( state: &AppState, _auth_mode: AuthMode, request_id: &str, -) -> Bytes { +) -> (Bytes, CompressionStats) { // Detect envelope shape. A parseable InvokeModel envelope takes the // re-emit path below (anthropic_version pinned first); a non-envelope // body (e.g. a Converse-shaped payload) still runs through the @@ -453,7 +497,7 @@ fn run_anthropic_compression( request_id, ); match outcome { - AnthropicOutcome::NoCompression => body.clone(), + AnthropicOutcome::NoCompression => (body.clone(), CompressionStats::default()), AnthropicOutcome::Passthrough { reason } => { tracing::info!( event = "bedrock_compression_passthrough", @@ -464,15 +508,26 @@ fn run_anthropic_compression( // The compressor's passthrough variants all leave bytes // unchanged. Forward the original. let _ = (PassthroughReason::ModeOff, PassthroughReason::NoMessages); // pin types - body.clone() + (body.clone(), CompressionStats::default()) } - AnthropicOutcome::Compressed { body: new_body, .. } => { + AnthropicOutcome::Compressed { + body: new_body, + tokens_before, + tokens_after, + strategies_applied, + .. + } => { + let stats = CompressionStats { + tokens_before: tokens_before as u64, + tokens_after: tokens_after as u64, + transforms: strategies_applied.iter().map(|s| s.to_string()).collect(), + }; if parsed_envelope { // Defence-in-depth: re-emit so anthropic_version is the // first key. With preserve_order this is a no-op on the // happy path. match BedrockEnvelope::ensure_anthropic_version_first(&new_body) { - Ok(b) => b, + Ok(b) => (b, stats), Err(e) => { tracing::error!( event = "bedrock_envelope_reemit_failed", @@ -480,11 +535,13 @@ fn run_anthropic_compression( error = %e, "bedrock invoke: failed to re-emit envelope; falling back to original body" ); - body.clone() + // Original bytes forwarded ⇒ no compression + // happened as far as the wire is concerned. + (body.clone(), CompressionStats::default()) } } } else { - new_body + (new_body, stats) } } } @@ -631,6 +688,7 @@ mod tests { vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new( "test".to_string(), )), + stats: std::sync::Arc::new(ledger::Ledger::in_memory()), }; let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/converse" .parse() @@ -669,6 +727,7 @@ mod tests { vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new( "test".to_string(), )), + stats: std::sync::Arc::new(ledger::Ledger::in_memory()), }; let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke" .parse() @@ -705,6 +764,7 @@ mod tests { vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new( "test".to_string(), )), + stats: std::sync::Arc::new(ledger::Ledger::in_memory()), }; let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke" .parse() diff --git a/crates/headroom-proxy/src/bedrock/invoke_streaming.rs b/crates/headroom-proxy/src/bedrock/invoke_streaming.rs index 64e0f5d2f2..7ff380c8bb 100644 --- a/crates/headroom-proxy/src/bedrock/invoke_streaming.rs +++ b/crates/headroom-proxy/src/bedrock/invoke_streaming.rs @@ -61,10 +61,13 @@ use crate::bedrock::eventstream_to_sse::{ translate_message, OutputMode, TranslateError, TranslateOutcome, }; use crate::bedrock::sigv4::{sign_request, SigningInputs}; +use crate::bedrock::CompressionStats; use crate::compression::{ compress_anthropic_request, Outcome as AnthropicOutcome, PassthroughReason, }; use crate::headers::filter_response_headers; +use crate::observability::capture::TEE_QUEUE_DEPTH as SSE_PARSER_QUEUE_DEPTH; +use crate::observability::{capture, ledger}; use crate::observability::{ observe_bedrock_invoke_latency, record_bedrock_eventstream_message, record_bedrock_invoke, }; @@ -153,9 +156,14 @@ pub async fn handle_invoke_streaming( "bedrock invoke-with-response-stream route received request" ); + // Constructed at handler ENTRY so every exit — including + // proxy-side rejections — records exactly once. + let mut pending_stats = + capture::start_pending(&state, ledger::provider::BEDROCK, &model_id, &request_id); + // 1. Live-zone compression for Anthropic-shape bodies (same as D1). let is_anthropic = is_anthropic_model_id(&model_id); - let outbound_body: Bytes = if is_anthropic { + let (outbound_body, compression_stats): (Bytes, CompressionStats) = if is_anthropic { run_anthropic_compression(&body, &state, auth_mode, &request_id) } else { tracing::info!( @@ -165,8 +173,15 @@ pub async fn handle_invoke_streaming( reason = "non_anthropic_vendor", "bedrock invoke-streaming: skipping compression for non-anthropic vendor" ); - body.clone() + (body.clone(), CompressionStats::default()) }; + if let Some(p) = pending_stats.as_mut() { + p.set_compression( + compression_stats.tokens_before, + compression_stats.tokens_after, + compression_stats.transforms, + ); + } // 2. Resolve the Bedrock streaming action from the inbound path and // build the upstream URL. @@ -179,6 +194,7 @@ pub async fn handle_invoke_streaming( path = %uri.path(), "bedrock invoke-streaming: unrecognized streaming action path" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::BAD_REQUEST, "bedrock_streaming_action_invalid", @@ -209,6 +225,7 @@ pub async fn handle_invoke_streaming( error = %msg, "bedrock invoke-streaming: failed to construct upstream URL" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::INTERNAL_SERVER_ERROR, "bedrock_endpoint_invalid", @@ -227,6 +244,7 @@ pub async fn handle_invoke_streaming( model_id = %model_id, "bedrock invoke-streaming: refusing to forward without AWS credentials" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::INTERNAL_SERVER_ERROR, "bedrock_credentials_missing", @@ -261,6 +279,7 @@ pub async fn handle_invoke_streaming( error = %e, "bedrock invoke-streaming: SigV4 signing failed" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::INTERNAL_SERVER_ERROR, "bedrock_sigv4_failed", @@ -298,6 +317,7 @@ pub async fn handle_invoke_streaming( error = %e, "bedrock invoke-streaming: invalid HTTP method" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::BAD_REQUEST, "bedrock_invalid_method", @@ -317,6 +337,9 @@ pub async fn handle_invoke_streaming( let upstream_resp = match upstream_resp { Ok(r) => r, Err(e) => { + if let Some(p) = pending_stats.take() { + p.finalize_failed(); + } tracing::warn!( event = "bedrock_upstream_error", request_id = %request_id, @@ -334,6 +357,11 @@ pub async fn handle_invoke_streaming( let status = StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + if !status.is_success() { + if let Some(p) = pending_stats.as_mut() { + p.mark_failed(); + } + } let upstream_content_type = upstream_resp .headers() .get(http::header::CONTENT_TYPE) @@ -373,10 +401,22 @@ pub async fn handle_invoke_streaming( // Even here we drop content-length: the proxy streams the // body and reqwest's transparent decompression may already // have changed the byte length on the way in. + // Usage capture: this is typically an error envelope (the + // pending event is already marked failed on non-2xx); parse + // it as JSON anyway so a 2xx JSON body still yields usage. resp_headers.remove(http::header::CONTENT_LENGTH); - let stream = upstream_resp - .bytes_stream() - .map(|r| r.map_err(std::io::Error::other)); + let capture_tx = pending_stats + .take() + .map(|p| capture::spawn_json_usage_capture(capture::ResponseShape::Bedrock, p)); + let stream = upstream_resp.bytes_stream().map(move |r| match r { + Ok(b) => { + if let Some(tx) = &capture_tx { + let _ = tx.try_send(b.clone()); + } + Ok(b) + } + Err(e) => Err(std::io::Error::other(e)), + }); let body_out = Body::from_stream(stream); return finish(status, resp_headers, body_out, &request_id); } @@ -410,7 +450,20 @@ pub async fn handle_invoke_streaming( resp_headers.insert(http::header::CONTENT_TYPE, v); } } - let body_out = Body::from_stream(upstream_stream); + // Usage capture: nothing else parses the frames in + // passthrough mode, so tee the raw bytes into a bounded + // EventStream telemetry parser (chunk / metadata usage; + // finalizes the ledger event when the stream closes). + let capture_tx = pending_stats + .take() + .map(capture::spawn_eventstream_usage_capture); + let teed = upstream_stream.map(move |r| { + if let (Ok(b), Some(tx)) = (&r, &capture_tx) { + let _ = tx.try_send(b.clone()); + } + r + }); + let body_out = Body::from_stream(teed); finish(status, resp_headers, body_out, &request_id) } OutputMode::Sse => { @@ -429,7 +482,8 @@ pub async fn handle_invoke_streaming( model_id.clone(), region.clone(), ); - let translated = tee_to_anthropic_state(translated, request_id.clone()); + let translated = + tee_to_anthropic_state(translated, request_id.clone(), pending_stats.take()); let body_out = Body::from_stream(translated); finish(status, resp_headers, body_out, &request_id) } @@ -475,200 +529,124 @@ where if done { return None; } - // First, drain any complete messages already in the - // parser's buffer (bytes from the previous chunk). - loop { - match parser.next_message() { - Ok(Some(msg)) => match translate_message(&msg, OutputMode::Sse) { - Ok(TranslateOutcome::Emit(frame)) => { - // PR-D3: per-message Prometheus metric. - // The label `event_type` is bounded by - // AWS's documented vocabulary (chunk, - // metadata, exception variants); not - // customer-controlled. - let event_type = msg.event_type().unwrap_or("unknown").to_string(); - record_bedrock_eventstream_message(&model_id, ®ion, &event_type); - tracing::debug!( - event = "bedrock_eventstream_message", - request_id = %request_id, - event_type = %event_type, - payload_bytes = msg.payload.len(), - "translated bedrock eventstream message" - ); - return Some(( - Ok(frame), - (parser, upstream, false, request_id, model_id, region), - )); - } - Ok(TranslateOutcome::Skip { event_type }) => { - tracing::warn!( - event = "bedrock_eventstream_unknown_event_type", - request_id = %request_id, - event_type = %event_type, - "skipping unknown bedrock eventstream message" - ); - // Loop and try the next message in the buffer. - continue; - } - Err(TranslateError::UpstreamException { payload_preview }) => { - tracing::warn!( - event = "bedrock_eventstream_upstream_exception", - request_id = %request_id, - payload_preview = %payload_preview, - "bedrock eventstream upstream exception" - ); - // Emit the exception as an Anthropic-shape - // SSE error frame so the client sees it. - let json = serde_json::json!({ - "type": "error", - "error": { - "type": "bedrock_upstream_exception", - "message": payload_preview, + // Drain-first alternation: after a chunk is pushed we + // re-enter the drain phase rather than pulling again, + // so a Skip'd message can never strand complete + // messages in the parser buffer. (The previous shape + // pulled a fresh upstream chunk after a Skip — + // delaying, and at stream end DROPPING, frames that + // were already buffered behind an unknown event type.) + 'parse: loop { + // Phase 1: emit complete messages already buffered. + loop { + match parser.next_message() { + Ok(Some(msg)) => match translate_message(&msg, OutputMode::Sse) { + Ok(TranslateOutcome::Emit(frame)) => { + // PR-D3: per-message Prometheus metric. + // The label `event_type` is bounded by + // AWS's documented vocabulary (chunk, + // metadata, exception variants); not + // customer-controlled. + let event_type = + msg.event_type().unwrap_or("unknown").to_string(); + record_bedrock_eventstream_message( + &model_id, + ®ion, + &event_type, + ); + tracing::debug!( + event = "bedrock_eventstream_message", + request_id = %request_id, + event_type = %event_type, + payload_bytes = msg.payload.len(), + "translated bedrock eventstream message" + ); + return Some(( + Ok(frame), + (parser, upstream, false, request_id, model_id, region), + )); + } + Ok(TranslateOutcome::Skip { event_type }) => { + tracing::warn!( + event = "bedrock_eventstream_unknown_event_type", + request_id = %request_id, + event_type = %event_type, + "skipping unknown bedrock eventstream message" + ); + // Loop and try the next message in the buffer. + continue; + } + Err(TranslateError::UpstreamException { payload_preview }) => { + tracing::warn!( + event = "bedrock_eventstream_upstream_exception", + request_id = %request_id, + payload_preview = %payload_preview, + "bedrock eventstream upstream exception" + ); + // Emit the exception as an Anthropic-shape + // SSE error frame so the client sees it. + let json = serde_json::json!({ + "type": "error", + "error": { + "type": "bedrock_upstream_exception", + "message": payload_preview, + } + }) + .to_string(); + let mut frame = Vec::with_capacity(json.len() + 32); + frame.extend_from_slice(b"event: error\ndata: "); + frame.extend_from_slice(json.as_bytes()); + frame.extend_from_slice(b"\n\n"); + return Some(( + Ok(Bytes::from(frame)), + (parser, upstream, true, request_id, model_id, region), + )); + } + Err(TranslateError::MissingEventType) => { + tracing::warn!( + event = "bedrock_eventstream_missing_event_type", + request_id = %request_id, + "bedrock eventstream message missing :event-type; emitting error frame" + ); + let frame = error_sse_frame( + "bedrock_eventstream_missing_event_type", + "Bedrock message missing :event-type header", + ); + return Some(( + Ok(frame), + (parser, upstream, true, request_id, model_id, region), + )); + } + }, + Ok(None) => break, + Err(parse_err) => { + let event_name = match &parse_err { + ParseError::PreludeCrcMismatch { .. } + | ParseError::MessageCrcMismatch { .. } => { + "bedrock_eventstream_crc_mismatch" } - }) - .to_string(); - let mut frame = Vec::with_capacity(json.len() + 32); - frame.extend_from_slice(b"event: error\ndata: "); - frame.extend_from_slice(json.as_bytes()); - frame.extend_from_slice(b"\n\n"); - return Some(( - Ok(Bytes::from(frame)), - (parser, upstream, true, request_id, model_id, region), - )); - } - Err(TranslateError::MissingEventType) => { + _ => "bedrock_eventstream_parse_failed", + }; tracing::warn!( - event = "bedrock_eventstream_missing_event_type", + event = event_name, request_id = %request_id, - "bedrock eventstream message missing :event-type; emitting error frame" - ); - let frame = error_sse_frame( - "bedrock_eventstream_missing_event_type", - "Bedrock message missing :event-type header", + error = %parse_err, + "bedrock eventstream parse failure; closing translated stream" ); + let frame = error_sse_frame(event_name, &parse_err.to_string()); return Some(( Ok(frame), (parser, upstream, true, request_id, model_id, region), )); } - }, - Ok(None) => break, - Err(parse_err) => { - let event_name = match &parse_err { - ParseError::PreludeCrcMismatch { .. } - | ParseError::MessageCrcMismatch { .. } => { - "bedrock_eventstream_crc_mismatch" - } - _ => "bedrock_eventstream_parse_failed", - }; - tracing::warn!( - event = event_name, - request_id = %request_id, - error = %parse_err, - "bedrock eventstream parse failure; closing translated stream" - ); - let frame = error_sse_frame(event_name, &parse_err.to_string()); - return Some(( - Ok(frame), - (parser, upstream, true, request_id, model_id, region), - )); } } - } - // Buffer drained; pull the next chunk from upstream. - loop { + // Phase 2: buffer fully drained — pull exactly + // one chunk, then loop back to the drain phase. match upstream.next().await { Some(Ok(chunk)) => { parser.push(&chunk); - // Loop back through the parser to emit any - // newly-complete messages. - match parser.next_message() { - Ok(Some(msg)) => match translate_message(&msg, OutputMode::Sse) { - Ok(TranslateOutcome::Emit(frame)) => { - // PR-D3: per-message Prometheus - // metric (mirror of the parser-buffer - // drain branch above). - let event_type = - msg.event_type().unwrap_or("unknown").to_string(); - record_bedrock_eventstream_message( - &model_id, - ®ion, - &event_type, - ); - tracing::debug!( - event = "bedrock_eventstream_message", - request_id = %request_id, - event_type = %event_type, - payload_bytes = msg.payload.len(), - "translated bedrock eventstream message" - ); - return Some(( - Ok(frame), - (parser, upstream, false, request_id, model_id, region), - )); - } - Ok(TranslateOutcome::Skip { event_type }) => { - tracing::warn!( - event = "bedrock_eventstream_unknown_event_type", - request_id = %request_id, - event_type = %event_type, - "skipping unknown bedrock eventstream message" - ); - // Continue draining the parser / - // pulling more chunks. - continue; - } - Err(TranslateError::UpstreamException { payload_preview }) => { - let json = serde_json::json!({ - "type": "error", - "error": { - "type": "bedrock_upstream_exception", - "message": payload_preview, - } - }) - .to_string(); - let mut frame = Vec::with_capacity(json.len() + 32); - frame.extend_from_slice(b"event: error\ndata: "); - frame.extend_from_slice(json.as_bytes()); - frame.extend_from_slice(b"\n\n"); - return Some(( - Ok(Bytes::from(frame)), - (parser, upstream, true, request_id, model_id, region), - )); - } - Err(TranslateError::MissingEventType) => { - let frame = error_sse_frame( - "bedrock_eventstream_missing_event_type", - "Bedrock message missing :event-type header", - ); - return Some(( - Ok(frame), - (parser, upstream, true, request_id, model_id, region), - )); - } - }, - Ok(None) => continue, - Err(parse_err) => { - let event_name = match &parse_err { - ParseError::PreludeCrcMismatch { .. } - | ParseError::MessageCrcMismatch { .. } => { - "bedrock_eventstream_crc_mismatch" - } - _ => "bedrock_eventstream_parse_failed", - }; - tracing::warn!( - event = event_name, - request_id = %request_id, - error = %parse_err, - "bedrock eventstream parse failure" - ); - let frame = error_sse_frame(event_name, &parse_err.to_string()); - return Some(( - Ok(frame), - (parser, upstream, true, request_id, model_id, region), - )); - } - } + continue 'parse; } Some(Err(e)) => { tracing::warn!( @@ -683,10 +661,11 @@ where )); } None => { - // End of upstream stream. If buffered bytes - // remain that did not parse into a message, - // log loudly — we are NOT silently dropping - // them. + // End of upstream stream. The drain phase + // above ran to Ok(None) first, so no + // complete message can be stranded here; + // leftover bytes are a truncated partial + // message — log loudly, never silently. if parser.buffered_len() > 0 { tracing::warn!( event = "bedrock_eventstream_truncated", @@ -713,6 +692,7 @@ where fn tee_to_anthropic_state( upstream: S, request_id: String, + pending_stats: Option, ) -> impl Stream> where S: Stream> + Send + 'static, @@ -720,7 +700,7 @@ where let (tx, rx) = tokio::sync::mpsc::channel::(SSE_PARSER_QUEUE_DEPTH); let parser_request_id = request_id.clone(); tokio::spawn(async move { - run_anthropic_state_machine(rx, parser_request_id).await; + run_anthropic_state_machine(rx, parser_request_id, pending_stats).await; }); upstream.map(move |r| { @@ -738,8 +718,6 @@ where }) } -const SSE_PARSER_QUEUE_DEPTH: usize = 256; - /// Dedicated state-machine task. Mirrors the /// `run_sse_state_machine(SseStreamKind::Anthropic, ...)` arm in /// `proxy.rs` so usage extraction works identically for direct @@ -747,17 +725,31 @@ const SSE_PARSER_QUEUE_DEPTH: usize = 256; async fn run_anthropic_state_machine( mut rx: tokio::sync::mpsc::Receiver, request_id: String, + pending_stats: Option, ) { use crate::sse::anthropic::AnthropicStreamState; use crate::sse::framing::SseFramer; let mut framer = SseFramer::new(); let mut state = AnthropicStreamState::new(); + // Converse-native frames (`metadata`, `messageStart`, …) carry no + // Anthropic `type`, so they arrive here as event-name-less frames + // the Anthropic state machine drops. Their camelCase usage block + // is the ONLY usage signal on a native Converse stream — scan + // them separately so `/converse-stream` requests don't record + // zero tokens. + let mut converse_usage = ledger::Usage::default(); while let Some(chunk) = rx.recv().await { framer.push(&chunk); while let Some(ev_result) = framer.next_event() { match ev_result { Ok(ev) => { + if ev.event_name.is_none() { + if let Ok(payload) = serde_json::from_slice::(&ev.data) { + capture::merge_stream_usage(&mut converse_usage, &payload); + } + continue; + } if let Err(e) = state.apply(ev) { tracing::warn!( request_id = %request_id, @@ -787,6 +779,16 @@ async fn run_anthropic_state_machine( blocks = state.blocks.len(), "bedrock translated stream: closed" ); + if let Some(mut p) = pending_stats { + // In-band `{"type":"error"}` on a 200 OK stream is not a + // billable success (same rule as the direct Anthropic lane). + if matches!(state.status, crate::sse::anthropic::StreamStatus::Errored) { + p.mark_failed(); + } + let mut usage = capture::usage_from_anthropic_state(&state); + capture::merge_max(&mut usage, converse_usage); + p.finalize(usage); + } } /// True when the content-type is `application/vnd.amazon.eventstream` @@ -866,7 +868,7 @@ fn run_anthropic_compression( state: &AppState, _auth_mode: AuthMode, request_id: &str, -) -> Bytes { +) -> (Bytes, CompressionStats) { use crate::bedrock::envelope::BedrockEnvelope; let parsed_envelope = BedrockEnvelope::parse(body).is_ok(); @@ -888,7 +890,7 @@ fn run_anthropic_compression( request_id, ); match outcome { - AnthropicOutcome::NoCompression => body.clone(), + AnthropicOutcome::NoCompression => (body.clone(), CompressionStats::default()), AnthropicOutcome::Passthrough { reason } => { tracing::info!( event = "bedrock_compression_passthrough", @@ -897,12 +899,23 @@ fn run_anthropic_compression( "bedrock invoke-streaming: passthrough" ); let _ = (PassthroughReason::ModeOff, PassthroughReason::NoMessages); - body.clone() + (body.clone(), CompressionStats::default()) } - AnthropicOutcome::Compressed { body: new_body, .. } => { + AnthropicOutcome::Compressed { + body: new_body, + tokens_before, + tokens_after, + strategies_applied, + .. + } => { + let stats = CompressionStats { + tokens_before: tokens_before as u64, + tokens_after: tokens_after as u64, + transforms: strategies_applied.iter().map(|s| s.to_string()).collect(), + }; if parsed_envelope { match BedrockEnvelope::ensure_anthropic_version_first(&new_body) { - Ok(b) => b, + Ok(b) => (b, stats), Err(e) => { tracing::error!( event = "bedrock_envelope_reemit_failed", @@ -910,11 +923,13 @@ fn run_anthropic_compression( error = %e, "bedrock invoke-streaming: failed to re-emit envelope" ); - body.clone() + // Original bytes forwarded ⇒ no compression + // happened as far as the wire is concerned. + (body.clone(), CompressionStats::default()) } } } else { - new_body + (new_body, stats) } } } @@ -1035,6 +1050,7 @@ mod tests { vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new( "test".to_string(), )), + stats: std::sync::Arc::new(ledger::Ledger::in_memory()), }; let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke-with-response-stream" .parse() @@ -1075,6 +1091,7 @@ mod tests { vertex_token_source: std::sync::Arc::new(crate::vertex::StaticTokenSource::new( "test".to_string(), )), + stats: std::sync::Arc::new(ledger::Ledger::in_memory()), }; let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/converse-stream" .parse() diff --git a/crates/headroom-proxy/src/bedrock/mod.rs b/crates/headroom-proxy/src/bedrock/mod.rs index d80f870924..417db513f8 100644 --- a/crates/headroom-proxy/src/bedrock/mod.rs +++ b/crates/headroom-proxy/src/bedrock/mod.rs @@ -54,6 +54,15 @@ pub mod invoke_streaming; pub mod sigv4; pub mod vendor; +/// Compression-side numbers the Bedrock handlers hand to the savings +/// ledger alongside the (possibly compressed) outbound body. +#[derive(Debug, Default, Clone)] +pub(crate) struct CompressionStats { + pub tokens_before: u64, + pub tokens_after: u64, + pub transforms: Vec, +} + pub use auth_mode_layer::classify_and_attach_auth_mode; pub use envelope::{BedrockEnvelope, EnvelopeError}; pub use eventstream::{ diff --git a/crates/headroom-proxy/src/bedrock/vendor.rs b/crates/headroom-proxy/src/bedrock/vendor.rs index bfa378be1d..13b15bf65c 100644 --- a/crates/headroom-proxy/src/bedrock/vendor.rs +++ b/crates/headroom-proxy/src/bedrock/vendor.rs @@ -13,7 +13,28 @@ /// vendor segment. Stripped (once) before vendor resolution. Kept to a /// closed, known set so an unrelated `something.anthropic.x` id is not /// mistaken for an Anthropic inference profile. -const GEO_PREFIXES: [&str; 4] = ["eu.", "us.", "apac.", "global."]; +/// +/// The set is the prefixes present in the vendored LiteLLM price +/// table (`data/model_prices_and_context_window.json`) — every one of +/// these fronts real `.anthropic.…` ids there. A missing entry +/// is not cosmetic: an unrecognized geo prefix makes +/// `is_anthropic_model_id` return false, silently skipping +/// compression for that region's inference profiles (`au.`/`jp.`/ +/// `us-gov.` were missing until review of the stats work caught it). +pub(crate) const GEO_PREFIXES: [&str; 7] = + ["eu.", "us.", "us-gov.", "apac.", "au.", "jp.", "global."]; + +/// Strip a cross-region inference-profile geo prefix, if present. +/// `eu.anthropic.claude-…` → `anthropic.claude-…`; ids without a +/// known geo prefix return unchanged. Shared with +/// `observability::pricing` so price lookups see the same canonical +/// id the vendor gate sees. +pub(crate) fn strip_geo_prefix(model_id: &str) -> &str { + GEO_PREFIXES + .iter() + .find_map(|p| model_id.strip_prefix(p)) + .unwrap_or(model_id) +} /// Resolve the canonical vendor of a Bedrock model id, stripping a /// cross-region inference-profile geo prefix first. @@ -21,10 +42,7 @@ const GEO_PREFIXES: [&str; 4] = ["eu.", "us.", "apac.", "global."]; /// `eu.anthropic.claude-…` → `anthropic`; `amazon.titan-…` → `amazon`; /// `global.amazon.nova-…` → `amazon`. pub fn canonical_vendor(model_id: &str) -> &str { - let stripped = GEO_PREFIXES - .iter() - .find_map(|p| model_id.strip_prefix(p)) - .unwrap_or(model_id); + let stripped = strip_geo_prefix(model_id); stripped.split('.').next().unwrap_or(stripped) } @@ -73,6 +91,17 @@ mod tests { assert!(is_anthropic_model_id( "global.anthropic.claude-haiku-4-5-20251001-v1:0" )); + // au./jp./us-gov. are real AWS geo prefixes (present in the + // vendored price table); they must gate as Anthropic too. + assert!(is_anthropic_model_id( + "au.anthropic.claude-sonnet-4-5-20250929-v1:0" + )); + assert!(is_anthropic_model_id( + "jp.anthropic.claude-haiku-4-5-20251001-v1:0" + )); + assert!(is_anthropic_model_id( + "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" + )); // Non-Anthropic vendors, including geo-prefixed, stay false. assert!(!is_anthropic_model_id("amazon.titan-text-express-v1")); assert!(!is_anthropic_model_id("meta.llama3-70b-instruct-v1:0")); diff --git a/crates/headroom-proxy/src/compression/model_limits.rs b/crates/headroom-proxy/src/compression/model_limits.rs index 36efb5675a..59e5254d2a 100644 --- a/crates/headroom-proxy/src/compression/model_limits.rs +++ b/crates/headroom-proxy/src/compression/model_limits.rs @@ -66,7 +66,10 @@ pub(crate) const DEFAULT_CONTEXT_WINDOW: u32 = 128_000; /// LiteLLM's vendored model price + context-window table. Refreshed /// via `scripts/refresh_model_limits.sh`. ~1.4MB; embedded into the /// binary so the proxy ships with no startup network dependency. -const VENDORED_JSON: &str = include_str!("../../data/model_prices_and_context_window.json"); +// Shared with `observability::pricing`, which reads the per-token +// cost fields of the same table — one embedded copy, two consumers. +pub(crate) const VENDORED_JSON: &str = + include_str!("../../data/model_prices_and_context_window.json"); /// Parsed lookup: model id → max input tokens. Built lazily on /// first call; subsequent calls reuse the same `HashMap`. diff --git a/crates/headroom-proxy/src/config.rs b/crates/headroom-proxy/src/config.rs index 181589754d..02ea33c13f 100644 --- a/crates/headroom-proxy/src/config.rs +++ b/crates/headroom-proxy/src/config.rs @@ -10,26 +10,26 @@ use url::Url; /// Compression mode policy for the `/v1/messages` endpoint. /// -/// Drives whether `compress_anthropic_request` does any work. PR-A1 -/// (Phase A lockdown) wires the flag in but both modes currently -/// passthrough — `live_zone` parses-but-warns until Phase B PR-B2 -/// fills in the live-zone-only block dispatcher. +/// Drives whether `compress_anthropic_request` does any work. `off` is +/// byte-faithful passthrough; `live_zone` routes the request through +/// the headroom-core live-zone dispatcher, which compresses only the +/// live-zone blocks (latest user message, latest tool/function/shell/ +/// patch outputs) via the per-content-type compressor table. /// /// We do NOT add an `icm` mode (the deleted code path) or a /// `passthrough` alias for `off` — those names are misleading. The /// only legal values are `off` (compression disabled) and `live_zone` -/// (compress only the live-zone blocks; not yet implemented). +/// (compress only the live-zone blocks). #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[clap(rename_all = "snake_case")] pub enum CompressionMode { /// Compression disabled. Body forwards byte-equal to upstream. - /// This is the default; Phase B will switch the default to - /// `live_zone` once that mode is implemented. + /// This is the default. Off, /// Compress only live-zone blocks (latest user message, - /// latest tool/function/shell/patch outputs). NOT YET IMPLEMENTED: - /// in PR-A1 this falls through to passthrough behaviour with a - /// loud warning. Phase B PR-B2 wires in the actual dispatcher. + /// latest tool/function/shell/patch outputs) via the + /// headroom-core live-zone dispatcher's per-content-type + /// compressors. LiveZone, } @@ -122,39 +122,35 @@ impl CacheControlAutoFrozen { } } -/// Phase F PR-F2.1 c3/6: feature flag for the per-auth-mode +/// Phase F PR-F2.1: feature flag for the per-auth-mode /// `CompressionPolicy` enforcement. /// -/// `disabled` (default until c6/6): the proxy still classifies -/// `auth_mode` and derives a `CompressionPolicy` for telemetry, but -/// every dispatcher and transform behaves as if the mode were `Payg` -/// — bit-for-bit current behaviour. +/// `enabled` (default, from c5/5 onward): the policy struct's +/// per-mode values take effect. For Subscription specifically, the +/// cache aligner is skipped and the dispatcher gates on +/// `policy.live_zone_compression_enabled()` (a no-op in F2.1 since +/// that helper currently always returns `true`, but kept as a hook +/// so F2.2 can flip without touching call sites). /// -/// `enabled`: the policy struct's per-mode values take effect. For -/// Subscription specifically, the cache aligner is skipped and the -/// dispatcher gates on `policy.live_zone_compression_enabled()` (a -/// no-op in F2.1 since that helper currently always returns `true`, -/// but kept as a hook so F2.2 can flip without touching call sites). -/// -/// Why a flag at all: F2.1 lands behind a default-disabled gate so -/// commits 4 and 5 of the PR don't ship behaviour change to default -/// users. Operators can flip this on for dogfooding before commit 6 -/// flips the default. Rollback: flip the env var back to `disabled` -/// — instant if config is hot-reloaded, redeploy otherwise. +/// `disabled`: the proxy still classifies `auth_mode` and derives a +/// `CompressionPolicy` for telemetry, but every dispatcher and +/// transform behaves as if the mode were `Payg` — bit-for-bit the +/// pre-F2.1 behaviour. Operators can flip back to this for rollback +/// if F2.1 surfaces a subscription regression — instant if config is +/// hot-reloaded, redeploy otherwise. /// /// Source priority: CLI flag → /// `HEADROOM_PROXY_AUTH_MODE_POLICY_ENFORCEMENT` env var → -/// default (`disabled`). +/// default (`enabled`). #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[clap(rename_all = "snake_case")] pub enum AuthModePolicyEnforcement { /// Per-mode policy IS enforced. Subscription users see no /// cache_aligner; the dispatcher reads - /// `policy.live_zone_compression_enabled()`. + /// `policy.live_zone_compression_enabled()`. Default. Enabled, /// Per-mode policy IS NOT enforced. Every mode runs the PAYG - /// pipeline, identical to pre-F2.1 behaviour. Default in F2.1 - /// commits 1–5 so the feature is dogfood-only until c6/6. + /// pipeline, identical to pre-F2.1 behaviour. Rollback opt-out. Disabled, } @@ -280,6 +276,25 @@ pub struct CliArgs { #[arg(long, env = "HEADROOM_PROXY_LISTEN", default_value = "0.0.0.0:8787")] pub listen: SocketAddr, + /// Restrict the Prometheus `/metrics` scrape endpoint to loopback + /// clients. Default `false` to preserve existing behaviour (the + /// endpoint is reachable from wherever the proxy is bound). Set to + /// `true` — recommended whenever `--listen` binds a non-loopback + /// address — so `/metrics` (token counts, per-session cache-hit + /// rates, rate-limit gauges) is served only to `127.0.0.1` / `::1` + /// and returns 403 otherwise. This is defense-in-depth alongside + /// firewalling the path. + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_METRICS_REQUIRE_LOOPBACK` + /// env var → default (`false`). + #[arg( + long = "metrics-require-loopback", + env = "HEADROOM_PROXY_METRICS_REQUIRE_LOOPBACK", + default_value_t = false, + action = clap::ArgAction::Set, + )] + pub metrics_require_loopback: bool, + /// Upstream base URL the proxy forwards to (e.g. http://127.0.0.1:8788). /// REQUIRED — there is no default; we want operators to be explicit. #[arg(long, env = "HEADROOM_PROXY_UPSTREAM")] @@ -340,11 +355,9 @@ pub struct CliArgs { /// Compression mode policy for `/v1/messages`. /// /// `off` (default): byte-faithful passthrough on every request. - /// `live_zone`: PR-B2 wired the dispatcher; PR-B2's per-type - /// compressors are no-ops, so the body still round-trips - /// byte-equal until PR-B3+ (which fills the per-type table). - /// The flag exists so the default can flip in one config - /// change once `live_zone` is the safer choice on real traffic. + /// `live_zone`: routes the request through the headroom-core + /// live-zone dispatcher, which compresses eligible live-zone + /// blocks via its per-content-type compressor table. /// /// Source priority: CLI flag → `HEADROOM_PROXY_COMPRESSION_MODE` /// env var → default (`off`). @@ -579,6 +592,34 @@ pub struct CliArgs { default_value = "https://www.googleapis.com/auth/cloud-platform" )] pub vertex_adc_scope: String, + + /// Native savings stats: record per-request savings/cost + /// telemetry and serve `/stats`, `/stats/timeseries`, + /// `/stats/events`, and `/dashboard`. When disabled, nothing is + /// recorded and those paths fall through to the catch-all + /// forwarder (i.e. they reach the upstream, matching the + /// pre-feature behaviour). + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_STATS` env var → + /// default (`false`). + #[arg( + long = "stats", + env = "HEADROOM_PROXY_STATS", + default_value_t = false, + action = clap::ArgAction::Set, + )] + pub stats: bool, + + /// Where the savings ledger persists its state. When unset, the + /// proxy uses `$HEADROOM_WORKSPACE_DIR/native_stats.json` when + /// that env var is set (how the Python proxy resolves its + /// workspace), else `~/.headroom/native_stats.json`; if no home + /// directory can be resolved, stats stay in-memory (logged). + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_STATS_PATH` + /// env var → workspace-derived default. + #[arg(long = "stats-path", env = "HEADROOM_PROXY_STATS_PATH")] + pub stats_path: Option, } fn parse_duration(s: &str) -> Result { @@ -623,6 +664,9 @@ pub struct Config { /// Runtime rollout state resolved from CLI/env. pub rollout: RolloutSnapshot, pub listen: SocketAddr, + /// Gate the Prometheus `/metrics` endpoint to loopback clients. + /// Default `false`; recommend `true` when `listen` is non-loopback. + pub metrics_require_loopback: bool, pub upstream: Url, pub upstream_timeout: Duration, pub upstream_connect_timeout: Duration, @@ -637,19 +681,19 @@ pub struct Config { /// Inherits `max_body_bytes` when not overridden. Bodies larger /// than this still forward, just unchanged. pub compression_max_body_bytes: u64, - /// Policy mode for compression on `/v1/messages`. PR-A1 lockdown: - /// both `Off` and `LiveZone` result in byte-faithful passthrough; - /// `LiveZone` additionally emits a `tracing::warn!` per request - /// because the dispatcher isn't implemented yet (Phase B PR-B2 - /// fills this in). + /// Policy mode for compression on `/v1/messages`. `Off` is + /// byte-faithful passthrough; `LiveZone` routes the request + /// through the headroom-core live-zone dispatcher, which + /// compresses eligible live-zone blocks via its per-content-type + /// compressor table. pub compression_mode: CompressionMode, /// Whether the live-zone dispatcher derives `frozen_message_count` /// automatically from customer `cache_control` markers. PR-A4 /// adds the derivation function (`compute_frozen_count`); Phase /// B's dispatcher consumes the resolved value here. pub cache_control_auto_frozen: CacheControlAutoFrozen, - /// Phase F PR-F2.1 c3/6: gate per-auth-mode `CompressionPolicy` - /// enforcement. `Disabled` until c6/6 flips the default. + /// Phase F PR-F2.1: gate per-auth-mode `CompressionPolicy` + /// enforcement. `Enabled` by default (from c5/5 onward). pub auth_mode_policy_enforcement: AuthModePolicyEnforcement, /// Whether to strip internal `x-headroom-*` headers from /// upstream-bound requests. PR-A5 default-on guard against @@ -692,6 +736,11 @@ pub struct Config { /// PR-D4: GCP ADC OAuth scope used when fetching the bearer /// token. Default `https://www.googleapis.com/auth/cloud-platform`. pub vertex_adc_scope: String, + /// Native savings stats: record per-request telemetry and serve + /// `/stats`, `/stats/timeseries`, `/stats/events`, `/dashboard`. + pub stats: bool, + /// Ledger persistence path. `None` keeps stats in-memory. + pub stats_path: Option, } impl Config { @@ -731,6 +780,7 @@ impl Config { Self { rollout: rollout.clone(), listen: args.listen, + metrics_require_loopback: args.metrics_require_loopback, upstream: args.upstream, upstream_timeout: args.upstream_timeout, upstream_connect_timeout: args.upstream_connect_timeout, @@ -758,6 +808,8 @@ impl Config { bedrock_validate_eventstream_crc: args.bedrock_validate_eventstream_crc, vertex_region: args.vertex_region, vertex_adc_scope: args.vertex_adc_scope, + stats: args.stats, + stats_path: args.stats_path.or_else(default_stats_path), } } @@ -767,6 +819,11 @@ impl Config { Self { rollout: RolloutSnapshot::default(), listen: "127.0.0.1:0".parse().unwrap(), + // Off by default in tests: the metrics integration tests + // scrape `/metrics` and assert 200, and oneshot-style tests + // carry no `ConnectInfo`. Tests that exercise the gate set + // this to `true` explicitly. + metrics_require_loopback: false, upstream, upstream_timeout: Duration::from_secs(60), upstream_connect_timeout: Duration::from_secs(5), @@ -816,8 +873,45 @@ impl Config { // only; the upstream URL is `upstream`). vertex_region: "us-central1".to_string(), vertex_adc_scope: "https://www.googleapis.com/auth/cloud-platform".to_string(), + // Stats on (routes mounted, recording active) but + // in-memory: tests never touch a real home directory. + stats: true, + stats_path: None, + } + } +} + +/// Default persistence location for the savings ledger. Honours the +/// same `HEADROOM_WORKSPACE_DIR` override the Python proxy uses for +/// its workspace, falling back to `~/.headroom`. `None` (no home +/// resolvable) keeps stats in-memory. +fn default_stats_path() -> Option { + if let Ok(dir) = std::env::var("HEADROOM_WORKSPACE_DIR") { + let dir = dir.trim(); + if !dir.is_empty() { + // Tilde-expand, matching the Python proxy's + // `workspace_dir()` semantics (its tests pin + // `HEADROOM_WORKSPACE_DIR=~/custom` → `$HOME/custom`). + let expanded = if let Some(rest) = dir.strip_prefix("~/") { + match home_dir() { + Some(home) => home.join(rest), + None => return None, + } + } else { + std::path::PathBuf::from(dir) + }; + return Some(expanded.join("native_stats.json")); } } + Some(home_dir()?.join(".headroom").join("native_stats.json")) +} + +fn home_dir() -> Option { + std::env::var("HOME") + .ok() + .filter(|h| !h.trim().is_empty()) + .or_else(|| std::env::var("USERPROFILE").ok()) + .map(std::path::PathBuf::from) } #[cfg(test)] @@ -863,4 +957,22 @@ mod rollout_input_tests { assert!(!config.enable_responses_streaming); assert!(!config.enable_bedrock_native); } + + #[test] + fn native_stats_require_explicit_opt_in() { + let base = ["headroom-proxy", "--upstream", "http://127.0.0.1:9"]; + let default_config = Config::from_cli(CliArgs::try_parse_from(base).unwrap()); + assert!(!default_config.stats); + + let enabled = Config::from_cli( + CliArgs::try_parse_from([ + "headroom-proxy", + "--upstream", + "http://127.0.0.1:9", + "--stats=true", + ]) + .unwrap(), + ); + assert!(enabled.stats); + } } diff --git a/crates/headroom-proxy/src/error.rs b/crates/headroom-proxy/src/error.rs index 61cf8a2bcc..91905bb829 100644 --- a/crates/headroom-proxy/src/error.rs +++ b/crates/headroom-proxy/src/error.rs @@ -55,30 +55,93 @@ impl From for ProxyError { impl IntoResponse for ProxyError { fn into_response(self) -> Response { - let (status, msg) = match &self { - ProxyError::Upstream(e) if e.is_timeout() => ( - StatusCode::GATEWAY_TIMEOUT, - format!("upstream timeout: {e}"), - ), - ProxyError::Upstream(e) if e.is_connect() => ( - StatusCode::BAD_GATEWAY, - format!("upstream connect error: {e}"), + // Full detail is for operators only. The `Display` impls above + // interpolate the source error (e.g. reqwest's chain), which for + // upstream failures can embed the upstream host/port and other + // internal wiring. We log that detail but return a generic, + // stable body to the client so an error response can't be used + // to fingerprint the upstream. The HTTP status still carries the + // actionable signal for well-behaved clients. + let detail = self.to_string(); + let (status, client_msg): (StatusCode, &'static str) = match &self { + ProxyError::Upstream(e) if e.is_timeout() => { + (StatusCode::GATEWAY_TIMEOUT, "upstream timeout") + } + ProxyError::Upstream(e) if e.is_connect() => { + (StatusCode::BAD_GATEWAY, "upstream connection failed") + } + ProxyError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream request failed"), + ProxyError::InvalidUpstream(_) => (StatusCode::BAD_GATEWAY, "upstream request failed"), + ProxyError::InvalidHeader(_) => (StatusCode::BAD_REQUEST, "invalid request"), + // PayloadTooLarge stays descriptive: it leaks no internal + // detail (the interpolated value is the client's own size vs + // the configured cap) and clients with retry-on-413 logic + // rely on recognizing it. See the PayloadTooLarge doc comment. + ProxyError::PayloadTooLarge(_) => ( + StatusCode::PAYLOAD_TOO_LARGE, + "request body exceeds configured limit", ), - ProxyError::Upstream(_) => (StatusCode::BAD_GATEWAY, self.to_string()), - ProxyError::InvalidUpstream(_) => (StatusCode::BAD_GATEWAY, self.to_string()), - ProxyError::InvalidHeader(_) => (StatusCode::BAD_REQUEST, self.to_string()), - ProxyError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()), - ProxyError::InvalidPath(_) => (StatusCode::BAD_REQUEST, self.to_string()), - ProxyError::WebSocket(_) => (StatusCode::BAD_GATEWAY, self.to_string()), - ProxyError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + ProxyError::InvalidPath(_) => (StatusCode::BAD_REQUEST, "invalid request"), + ProxyError::WebSocket(_) => (StatusCode::BAD_GATEWAY, "upstream request failed"), + ProxyError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"), + // CompressionStartup is a startup-time error, not a // per-request one — but if it ever surfaces in the // handler path, surface as 500 rather than panic. ProxyError::CompressionStartup(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + (StatusCode::INTERNAL_SERVER_ERROR, "internal error") } }; - tracing::warn!(error = %msg, "proxy error"); - (status, msg).into_response() + tracing::warn!(status = %status.as_u16(), error = %detail, "proxy error"); + (status, client_msg).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + + async fn status_and_body(err: ProxyError) -> (StatusCode, String) { + let resp = err.into_response(); + let status = resp.status(); + let bytes = to_bytes(resp.into_body(), 1024).await.unwrap(); + (status, String::from_utf8_lossy(&bytes).to_string()) + } + + // The interpolated detail (which for real upstream errors can embed + // the upstream host/port) must NEVER reach the client body. Before + // the hardening these bodies were `self.to_string()` and DID contain + // the detail, so these assertions fail on old code. + #[tokio::test] + async fn invalid_header_detail_is_not_leaked_to_client() { + let (status, body) = status_and_body(ProxyError::InvalidHeader( + "upstream-secret-host:9443".into(), + )) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body, "invalid request"); + assert!(!body.contains("upstream-secret-host")); + } + + #[tokio::test] + async fn invalid_upstream_detail_is_not_leaked_to_client() { + let (status, body) = status_and_body(ProxyError::InvalidUpstream( + "http://10.0.0.5:8788/internal".into(), + )) + .await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!(body, "upstream request failed"); + assert!(!body.contains("10.0.0.5")); + } + + #[tokio::test] + async fn payload_too_large_stays_descriptive() { + // Deliberately kept descriptive: no internal detail, and + // retry-on-413 clients rely on recognizing it. + let (status, body) = + status_and_body(ProxyError::PayloadTooLarge("body exceeds cap".into())).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(body, "request body exceeds configured limit"); } } diff --git a/crates/headroom-proxy/src/main.rs b/crates/headroom-proxy/src/main.rs index 005cd0f900..6dfd9bf335 100644 --- a/crates/headroom-proxy/src/main.rs +++ b/crates/headroom-proxy/src/main.rs @@ -56,6 +56,24 @@ async fn main() -> Result<(), Box> { ); } + // The ~261 MB Kompress ONNX session must never build on the request + // path: dispatch answers NoOp until the model is ready. Warm it in + // the background at startup so steady-state traffic meets a settled + // slot; a byte-pipe proxy (--compression off) never loads a model. + if config.compression { + if let Err(e) = std::thread::Builder::new() + .name("live-zone-warmup".into()) + .spawn(|| { + let ready = headroom_core::transforms::live_zone::warm_live_zone_compressors(); + tracing::info!(event = "live_zone_warmup_complete", kompress_ready = ready); + }) + { + // Non-fatal: the dispatcher's lazy path still initializes + // off-request; only the eager warmup is lost. + tracing::warn!(event = "live_zone_warmup_spawn_failed", error = %e); + } + } + let mut state = AppState::new(config.clone())?; // PR-D1: resolve AWS credentials at startup via the `aws-config` @@ -87,6 +105,42 @@ async fn main() -> Result<(), Box> { } } + // Savings observability: with the master compression switch on + // but the mode off, the dispatcher passes every body through and + // tokens_saved reads 0 on the dashboard with no hint why. Say so + // once at startup instead of letting the zero look like a bug. + if config.compression + && matches!( + config.compression_mode, + headroom_proxy::config::CompressionMode::Off + ) + { + tracing::warn!( + event = "compression_mode_off", + "--compression is on but --compression-mode is `off`: bodies \ + pass through unmodified and the dashboard's compression \ + savings will read 0. Set --compression-mode live_zone to \ + engage the dispatcher." + ); + } + + // Savings ledger: background persistence while serving, plus a + // best-effort final flush after the server drains (capture tasks + // are detached, so a request finishing in the same instant as + // shutdown may still miss it — see observability::capture docs). + let stats_ledger = state.stats.clone(); + if config.stats { + // Pay the vendored price-table parse now, not on the first + // recorded request. + headroom_proxy::observability::pricing::warm(); + tracing::info!( + event = "stats_enabled", + path = ?stats_ledger.path().map(|p| p.display().to_string()), + "native savings stats active (/stats, /dashboard)" + ); + headroom_proxy::observability::ledger::spawn_flusher(stats_ledger.clone()); + } + let app = build_app(state).into_make_service_with_connect_info::(); let listener = tokio::net::TcpListener::bind(config.listen).await?; @@ -104,6 +158,8 @@ async fn main() -> Result<(), Box> { }) .await?; + stats_ledger.flush().await; + Ok(()) } diff --git a/crates/headroom-proxy/src/observability/capture.rs b/crates/headroom-proxy/src/observability/capture.rs new file mode 100644 index 0000000000..f5cd8b7050 --- /dev/null +++ b/crates/headroom-proxy/src/observability/capture.rs @@ -0,0 +1,594 @@ +//! Response-usage capture — Phase H. Shared by every lane. +//! +//! Recording a request's savings needs the *response's* token usage, +//! which arrives in four wire shapes: +//! +//! - JSON bodies (non-streaming Anthropic / OpenAI / Bedrock / +//! Vertex responses), +//! - Anthropic-style SSE streams (direct, Vertex, and Bedrock's +//! SSE-translated mode) — already accumulated by +//! [`crate::sse::anthropic::AnthropicStreamState`], +//! - Converse-native frames whose usage keys are camelCase and carry +//! no Anthropic `type` (the state machine drops them), +//! - Bedrock binary EventStream passthrough, where nothing parses +//! the frames today. +//! +//! This module provides one [`PendingRecord`] (a request's +//! half-built ledger event plus the deferred-finalization contract: +//! **record only after the response is fully observed**, so +//! streaming token counts are real instead of zero) and the capture +//! tasks that feed it. Every capture path is a bounded, best-effort +//! tee off the client byte path — a stalled or hostile response can +//! delay telemetry, never the client. +//! +//! Best-effort has one deliberate edge: capture tasks are plain +//! `tokio::spawn`s with no shutdown drain, so requests still +//! in-flight when the process exits are dropped without recording. +//! Tracking them against shutdown would couple the byte path to +//! telemetry lifetime — the wrong trade for a stats ledger. + +use std::sync::Arc; +use std::time::Instant; + +use base64::Engine as _; +use serde_json::Value; + +use super::ledger::{Ledger, RequestEvent, Usage}; + +/// Cap for buffered non-streaming response bodies awaiting a usage +/// parse. Bigger bodies finalize with zero usage (tokens/USD from +/// compression estimates only) rather than growing memory. +const JSON_CAPTURE_CAP: usize = 2 * 1024 * 1024; + +/// Queue depth for every telemetry tee (capture bodies and the SSE +/// parser feeds in proxy/bedrock/vertex). Bounded so a stalled +/// parser task can never make the byte path wait: senders `try_send` +/// and drop on a full queue. +pub(crate) const TEE_QUEUE_DEPTH: usize = 256; + +/// A request's ledger event, waiting for its response to finish. +/// +/// Constructed when the request is forwarded; finalized exactly once +/// when the response is fully observed (stream closed / body ended / +/// transport failed). Latency is measured here so every lane reports +/// the same thing: time from forwarding decision to response fully +/// observed. +pub struct PendingRecord { + ledger: Arc, + event: RequestEvent, + started: Instant, +} + +impl PendingRecord { + pub fn new(ledger: Arc, event: RequestEvent, started: Instant) -> Self { + Self { + ledger, + event, + started, + } + } + + /// Mark the upstream outcome failed (non-2xx or transport error) + /// before finalization. + pub fn mark_failed(&mut self) { + self.event.failed = true; + } + + /// Supply the model id the request side knows (path parameter, + /// or the buffered request body when compression is on). + pub fn set_model(&mut self, model: &str) { + self.event.set_model(model); + } + + /// Supply the model id learned from the *response*. Recording is + /// not gated on the compression buffer (compression defaults + /// off), so on a pure passthrough request this is the only place + /// the model is known. Never overrides a request-side value. + pub fn set_model_if_unknown(&mut self, model: &str) { + if self.event.model_is_unknown() { + self.event.set_model(model); + } + } + + /// Attach the compression dispatcher's before/after token + /// estimate and the strategies it applied. + pub fn set_compression(&mut self, before: u64, after: u64, transforms: Vec) { + self.event.tokens_before = before; + self.event.tokens_after = after; + self.event.transforms = transforms; + } + + /// Record with the captured usage. Consumes the record — each + /// request records exactly once. + pub fn finalize(mut self, usage: Usage) { + self.event.usage = usage; + self.event.latency_ms = self.started.elapsed().as_millis() as u64; + self.ledger.record(self.event); + } + + /// Record a failure (zero usage, no savings — the ledger's + /// failed-path accounting). + pub fn finalize_failed(mut self) { + self.mark_failed(); + self.finalize(Usage::default()); + } +} + +/// Build the pending ledger event for a request entering a handler +/// (`None` when `--stats` is off). One call site per lane keeps the +/// "constructed at handler entry so every exit records exactly once" +/// rule easy to audit. +pub fn start_pending( + state: &crate::proxy::AppState, + provider: &'static str, + model: &str, + request_id: &str, +) -> Option { + state.config.stats.then(|| { + PendingRecord::new( + state.stats.clone(), + RequestEvent::new(provider, model, request_id), + Instant::now(), + ) + }) +} + +/// Finalize a pending record as failed on a proxy-side rejection +/// (bad path, missing credentials, signing failure, oversized body, +/// …). Sugar for handler early-return paths, so "every request that +/// enters a handler produces exactly one record" survives rejects: a +/// credentials outage must show in `/stats` as a failure spike, not +/// as zero traffic. +pub fn finalize_rejected(pending: Option) { + if let Some(p) = pending { + p.finalize_failed(); + } +} + +/// Which JSON body shape to expect when parsing a non-streaming +/// response for usage. +#[derive(Debug, Clone, Copy)] +pub enum ResponseShape { + Anthropic, + OpenAiChat, + OpenAiResponses, + /// Bedrock sync responses: InvokeModel bodies are Anthropic + /// snake_case, Converse bodies are camelCase — try both. + Bedrock, +} + +/// Extract usage from a complete response body. `None` when the +/// body has no recognisable usage block (error envelopes, non-JSON). +fn usage_from_response_json(shape: ResponseShape, v: &Value) -> Option { + usage_from_usage_block(shape, v.get("usage")?) +} + +/// Parse a bare `usage` object (already extracted from its +/// envelope) — the SSE state machines hold usage in this form. +pub fn usage_from_usage_block(shape: ResponseShape, usage: &Value) -> Option { + match shape { + ResponseShape::Anthropic => snake_usage(usage), + ResponseShape::OpenAiChat => openai_chat_usage(usage), + ResponseShape::OpenAiResponses => openai_responses_usage(usage), + ResponseShape::Bedrock => snake_usage(usage).or_else(|| camel_usage(usage)), + } +} + +fn u64_at(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_u64) +} + +/// Anthropic `usage` block (also Bedrock InvokeModel for Anthropic +/// models): `input_tokens` already EXCLUDES cache reads/writes. +fn snake_usage(usage: &Value) -> Option { + let input = u64_at(usage, "input_tokens"); + let output = u64_at(usage, "output_tokens"); + if input.is_none() && output.is_none() { + return None; + } + Some(Usage { + input_tokens: input.unwrap_or(0), + output_tokens: output.unwrap_or(0), + cache_read_tokens: u64_at(usage, "cache_read_input_tokens").unwrap_or(0), + cache_write_tokens: u64_at(usage, "cache_creation_input_tokens").unwrap_or(0), + }) +} + +/// Bedrock Converse `usage` block: camelCase, and `inputTokens` +/// likewise excludes cache tokens. +/// +/// The cache fields fall back to the `*InputTokenCount` spelling +/// after `*InputTokens`. Reading only one name would silently report +/// zero cache tokens against a response that uses the other, and a +/// miss costs one map lookup — cheap insurance on a field whose +/// whole job is to be believed. +fn camel_usage(usage: &Value) -> Option { + let input = u64_at(usage, "inputTokens"); + let output = u64_at(usage, "outputTokens"); + if input.is_none() && output.is_none() { + return None; + } + Some(Usage { + input_tokens: input.unwrap_or(0), + output_tokens: output.unwrap_or(0), + cache_read_tokens: u64_at(usage, "cacheReadInputTokens") + .or_else(|| u64_at(usage, "cacheReadInputTokenCount")) + .unwrap_or(0), + cache_write_tokens: u64_at(usage, "cacheWriteInputTokens") + .or_else(|| u64_at(usage, "cacheWriteInputTokenCount")) + .unwrap_or(0), + }) +} + +/// OpenAI Chat Completions: `prompt_tokens` INCLUDES cached tokens — +/// normalise to the ledger's Anthropic semantics (input = uncached). +fn openai_chat_usage(usage: &Value) -> Option { + let prompt = u64_at(usage, "prompt_tokens")?; + let cached = usage + .get("prompt_tokens_details") + .and_then(|d| u64_at(d, "cached_tokens")) + .unwrap_or(0) + .min(prompt); + Some(Usage { + input_tokens: prompt - cached, + output_tokens: u64_at(usage, "completion_tokens").unwrap_or(0), + cache_read_tokens: cached, + cache_write_tokens: 0, + }) +} + +/// OpenAI Responses: `input_tokens` INCLUDES cached tokens — same +/// normalisation. +fn openai_responses_usage(usage: &Value) -> Option { + let input = u64_at(usage, "input_tokens")?; + let cached = usage + .get("input_tokens_details") + .and_then(|d| u64_at(d, "cached_tokens")) + .unwrap_or(0) + .min(input); + Some(Usage { + input_tokens: input - cached, + output_tokens: u64_at(usage, "output_tokens").unwrap_or(0), + cache_read_tokens: cached, + cache_write_tokens: 0, + }) +} + +/// Usage accumulated by the shared Anthropic SSE state machine. +pub fn usage_from_anthropic_state(state: &crate::sse::anthropic::AnthropicStreamState) -> Usage { + Usage { + input_tokens: state.usage.input_tokens, + output_tokens: state.usage.output_tokens, + cache_read_tokens: state.usage.cache_read_input_tokens, + cache_write_tokens: state.usage.cache_creation_input_tokens, + } +} + +/// Fold one streaming frame's JSON payload into a usage accumulator. +/// Handles every Bedrock/Anthropic stream shape (fields are monotone +/// within a stream, so `max` merge is safe against repeats): +/// +/// - Anthropic SSE events: `message_start.message.usage`, +/// `message_delta.usage` (snake_case), +/// - Converse-native frames: bare `usage` with camelCase keys +/// (`metadata` events — no Anthropic `type` field at all), +/// - Bedrock InvokeModel EventStream chunks: `{"bytes": ""}` +/// wrapping an Anthropic SSE event (recursed after decode). +pub fn merge_stream_usage(acc: &mut Usage, payload: &Value) { + merge_stream_usage_depth(acc, payload, 0); +} + +/// Real Bedrock wraps exactly once (`{"bytes": }`); the recursion exists only for that unwrap. Cap it so a +/// crafted deeply-nested base64 payload can't blow the stack — a +/// stack overflow aborts the whole process, not just this task. +const MAX_BYTES_UNWRAP_DEPTH: u8 = 4; + +fn merge_stream_usage_depth(acc: &mut Usage, payload: &Value, depth: u8) { + // InvokeModel EventStream chunk: base64-wrapped inner event. + if let Some(b64) = payload.get("bytes").and_then(Value::as_str) { + if depth >= MAX_BYTES_UNWRAP_DEPTH { + tracing::warn!( + event = "stats_capture_bytes_depth_exceeded", + depth = depth, + "nested {{\"bytes\": …}} wrapping exceeded the unwrap cap; \ + ignoring the payload (real Bedrock wraps exactly once)" + ); + return; + } + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64) { + if let Ok(inner) = serde_json::from_slice::(&decoded) { + merge_stream_usage_depth(acc, &inner, depth + 1); + } + } + return; + } + // message_start carries usage nested under `message`. + if let Some(u) = payload + .get("message") + .and_then(|m| m.get("usage")) + .and_then(snake_usage) + { + merge_max(acc, u); + } + // message_delta (and some terminal frames) carry a bare `usage`. + if let Some(usage) = payload.get("usage") { + if let Some(u) = snake_usage(usage).or_else(|| camel_usage(usage)) { + merge_max(acc, u); + } + } +} + +/// Take the field-wise maximum of two usage snapshots (usage fields +/// are monotone within a stream, so `max` merge is repeat-safe). +pub(crate) fn merge_max(acc: &mut Usage, u: Usage) { + acc.input_tokens = acc.input_tokens.max(u.input_tokens); + acc.output_tokens = acc.output_tokens.max(u.output_tokens); + acc.cache_read_tokens = acc.cache_read_tokens.max(u.cache_read_tokens); + acc.cache_write_tokens = acc.cache_write_tokens.max(u.cache_write_tokens); +} + +/// Bounded tee → JSON-body usage capture. Returns the sender to tee +/// response chunks into; finalizes `pending` when the channel +/// closes (i.e. the response body finished or the client hung up). +pub fn spawn_json_usage_capture( + shape: ResponseShape, + mut pending: PendingRecord, +) -> tokio::sync::mpsc::Sender { + let (tx, mut rx) = tokio::sync::mpsc::channel::(TEE_QUEUE_DEPTH); + tokio::spawn(async move { + let mut buf: Vec = Vec::new(); + let mut truncated = false; + while let Some(chunk) = rx.recv().await { + if truncated { + continue; // keep draining so the tee never backpressures + } + if buf.len() + chunk.len() > JSON_CAPTURE_CAP { + // No silent fallbacks: the request still records, but + // its usage will read zero — say why, once. + tracing::warn!( + event = "stats_capture_body_too_large", + cap_bytes = JSON_CAPTURE_CAP, + "response body exceeded the usage-capture buffer; \ + this request records with compression-side numbers \ + only (tokens/USD from usage read as 0)" + ); + truncated = true; + buf.clear(); + continue; + } + buf.extend_from_slice(&chunk); + } + let mut usage = Usage::default(); + if !truncated { + match serde_json::from_slice::(&buf) { + Ok(v) => { + if let Some(model) = v.get("model").and_then(Value::as_str) { + pending.set_model_if_unknown(model); + } + // A 2xx from an LLM endpoint carries a usage block + // in every shape we know. A miss means the wire + // format moved (or this isn't the body we think it + // is), and the request would silently record $0 — + // the phantom-zero this module exists to avoid. + // Same no-silent-fallbacks rule as the cap above. + match usage_from_response_json(shape, &v) { + Some(u) => usage = u, + None => tracing::warn!( + event = "stats_capture_no_usage_block", + shape = ?shape, + "response parsed as JSON but carried no recognisable \ + usage block; recording this request with zero tokens \ + and $0 spend" + ), + } + } + Err(e) => tracing::warn!( + event = "stats_capture_body_not_json", + error = %e, + "response body did not parse as JSON; recording this request \ + with zero tokens and $0 spend" + ), + } + } + pending.finalize(usage); + }); + tx +} + +/// Bounded tee → Bedrock binary EventStream usage capture, for the +/// passthrough mode where no other parser sees the frames. CRC +/// validation is off — this is the telemetry side; enforcement +/// happens (or not, per config) on the byte path. +pub fn spawn_eventstream_usage_capture( + pending: PendingRecord, +) -> tokio::sync::mpsc::Sender { + use crate::bedrock::eventstream::{CrcValidation, EventStreamParser}; + let (tx, mut rx) = tokio::sync::mpsc::channel::(TEE_QUEUE_DEPTH); + tokio::spawn(async move { + let mut parser = EventStreamParser::new().with_crc_validation(CrcValidation::No); + let mut usage = Usage::default(); + 'outer: while let Some(chunk) = rx.recv().await { + parser.push(&chunk); + loop { + match parser.next_message() { + Ok(Some(msg)) => { + if let Ok(payload) = serde_json::from_slice::(&msg.payload) { + merge_stream_usage(&mut usage, &payload); + } + } + Ok(None) => break, + Err(_) => { + // Telemetry parser out of sync — stop and + // record what accumulated so far. The channel + // is simply dropped; the tee's `try_send` on a + // closed channel is a no-op, so the byte path + // never notices. + break 'outer; + } + } + } + } + pending.finalize(usage); + }); + tx +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn anthropic_json_usage_parses() { + let v = json!({"usage": {"input_tokens": 10, "output_tokens": 5, + "cache_read_input_tokens": 7, "cache_creation_input_tokens": 3}}); + let u = usage_from_response_json(ResponseShape::Anthropic, &v).unwrap(); + assert_eq!( + u, + Usage { + input_tokens: 10, + output_tokens: 5, + cache_read_tokens: 7, + cache_write_tokens: 3 + } + ); + } + + #[test] + fn openai_chat_normalises_cached_out_of_prompt() { + let v = json!({"usage": {"prompt_tokens": 100, "completion_tokens": 20, + "prompt_tokens_details": {"cached_tokens": 60}}}); + let u = usage_from_response_json(ResponseShape::OpenAiChat, &v).unwrap(); + assert_eq!(u.input_tokens, 40, "prompt minus cached"); + assert_eq!(u.cache_read_tokens, 60); + assert_eq!(u.output_tokens, 20); + // Pathological cached > prompt clamps instead of underflowing. + let v = json!({"usage": {"prompt_tokens": 10, + "prompt_tokens_details": {"cached_tokens": 60}}}); + let u = usage_from_response_json(ResponseShape::OpenAiChat, &v).unwrap(); + assert_eq!(u.input_tokens, 0); + assert_eq!(u.cache_read_tokens, 10); + } + + #[test] + fn openai_responses_normalises_cached_out_of_input() { + let v = json!({"usage": {"input_tokens": 80, "output_tokens": 9, + "input_tokens_details": {"cached_tokens": 50}}}); + let u = usage_from_response_json(ResponseShape::OpenAiResponses, &v).unwrap(); + assert_eq!(u.input_tokens, 30); + assert_eq!(u.cache_read_tokens, 50); + } + + #[test] + fn bedrock_shape_accepts_snake_and_camel() { + let snake = json!({"usage": {"input_tokens": 10, "output_tokens": 5}}); + let camel = json!({"usage": {"inputTokens": 11, "outputTokens": 6, + "cacheReadInputTokens": 4, "cacheWriteInputTokens": 2}}); + assert_eq!( + usage_from_response_json(ResponseShape::Bedrock, &snake) + .unwrap() + .input_tokens, + 10 + ); + let u = usage_from_response_json(ResponseShape::Bedrock, &camel).unwrap(); + assert_eq!(u.input_tokens, 11); + assert_eq!(u.cache_read_tokens, 4); + assert_eq!(u.cache_write_tokens, 2); + } + + /// The cache fields accept the `*InputTokenCount` spelling too. + /// Untested, this fallback reads as dead code and invites + /// deletion — at which point any response using that spelling + /// silently reports zero cache tokens. + #[test] + fn bedrock_camel_usage_accepts_token_count_suffix() { + let v = json!({"usage": {"inputTokens": 11, "outputTokens": 6, + "cacheReadInputTokenCount": 4, "cacheWriteInputTokenCount": 2}}); + let u = usage_from_response_json(ResponseShape::Bedrock, &v).unwrap(); + assert_eq!(u.cache_read_tokens, 4); + assert_eq!(u.cache_write_tokens, 2); + + // The unsuffixed spelling wins when both are present. + let both = json!({"usage": {"inputTokens": 11, "outputTokens": 6, + "cacheReadInputTokens": 9, "cacheReadInputTokenCount": 4}}); + let u = usage_from_response_json(ResponseShape::Bedrock, &both).unwrap(); + assert_eq!(u.cache_read_tokens, 9); + } + + #[test] + fn error_envelope_yields_none() { + let v = json!({"error": {"type": "overloaded_error"}}); + assert!(usage_from_response_json(ResponseShape::Anthropic, &v).is_none()); + let v = json!({"usage": {"totalTokens": 5}}); + assert!(usage_from_response_json(ResponseShape::Bedrock, &v).is_none()); + } + + #[test] + fn stream_merge_handles_all_three_wire_shapes() { + let mut acc = Usage::default(); + // 1. Anthropic message_start (nested under message). + merge_stream_usage( + &mut acc, + &json!({"type": "message_start", + "message": {"usage": {"input_tokens": 25, "output_tokens": 1, + "cache_read_input_tokens": 9}}}), + ); + // 2. Anthropic message_delta (bare usage, growing output). + merge_stream_usage( + &mut acc, + &json!({"type": "message_delta", "usage": {"output_tokens": 42}}), + ); + // 3. Converse metadata (camelCase, no `type`). + merge_stream_usage( + &mut acc, + &json!({"usage": {"inputTokens": 25, "outputTokens": 42, + "cacheWriteInputTokens": 3}}), + ); + assert_eq!(acc.input_tokens, 25); + assert_eq!(acc.output_tokens, 42); + assert_eq!(acc.cache_read_tokens, 9); + assert_eq!(acc.cache_write_tokens, 3); + } + + /// A crafted payload nesting `{"bytes": …}` inside itself must + /// hit the unwrap cap instead of recursing until the stack blows + /// (a stack overflow aborts the whole process). Real Bedrock + /// wraps exactly once. + #[test] + fn stream_merge_bounds_nested_base64_depth() { + let engine = &base64::engine::general_purpose::STANDARD; + // usage buried 10 wraps deep — far past the cap. + let mut v = json!({"type": "message_delta", "usage": {"output_tokens": 99}}); + for _ in 0..10 { + v = json!({"bytes": engine.encode(v.to_string())}); + } + let mut acc = Usage::default(); + merge_stream_usage(&mut acc, &v); // must return, not overflow + assert_eq!( + acc.output_tokens, 0, + "usage beyond the unwrap cap is ignored, not chased" + ); + // …while the real single-wrap shape still works. + let single = json!({"bytes": engine.encode( + json!({"type": "message_delta", "usage": {"output_tokens": 7}}).to_string() + )}); + merge_stream_usage(&mut acc, &single); + assert_eq!(acc.output_tokens, 7); + } + + #[test] + fn stream_merge_decodes_invoke_model_base64_chunks() { + let inner = json!({"type": "message_delta", + "usage": {"output_tokens": 17, "input_tokens": 12}}); + let b64 = + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&inner).unwrap()); + let mut acc = Usage::default(); + merge_stream_usage(&mut acc, &json!({"bytes": b64, "p": "pad"})); + assert_eq!(acc.output_tokens, 17); + assert_eq!(acc.input_tokens, 12); + // Garbage base64 is ignored, never panics. + merge_stream_usage(&mut acc, &json!({"bytes": "!!!not-base64!!!"})); + assert_eq!(acc.output_tokens, 17); + } +} diff --git a/crates/headroom-proxy/src/observability/dashboard.html b/crates/headroom-proxy/src/observability/dashboard.html new file mode 100644 index 0000000000..7233c4caf2 --- /dev/null +++ b/crates/headroom-proxy/src/observability/dashboard.html @@ -0,0 +1,722 @@ + + + + + +Headroom Proxy — Savings + + + + + +
+
+

Headroom Proxy — Savings

+ + + + + +
+ + + +
+ +
+

Spend vs savings over time

+

Bar height is what this traffic would have cost without Headroom.

+
+
+ +
+
+ +
+ +
+
+

By provider

+

Requests routed through each lane ().

+
+
+
+

By model

+

Top models by USD saved (lifetime).

+
+
+
+ +
+

Recent requests

+

Last requests through the proxy (newest first). Failed requests accrue no savings.

+
+

Savings are valued from the vendored LiteLLM price table; models missing from it record $0 (logged by the proxy).

+
+
+ + + + diff --git a/crates/headroom-proxy/src/observability/ledger.rs b/crates/headroom-proxy/src/observability/ledger.rs new file mode 100644 index 0000000000..515b260a1d --- /dev/null +++ b/crates/headroom-proxy/src/observability/ledger.rs @@ -0,0 +1,1674 @@ +//! Native savings ledger — Phase H. Request-level savings/cost +//! telemetry for the Rust proxy, served by `/stats`, `/stats/timeseries`, +//! `/stats/events`, and the `/dashboard` page. +//! +//! # Design +//! +//! Everything is **one fold, many views**: each finished request +//! becomes a [`RequestEvent`], and every aggregate the proxy exposes +//! — lifetime, since-boot session, per-provider, per-model, hourly +//! and daily buckets — is the same [`Totals::fold`] applied under a +//! different key. There is exactly one place where accounting +//! happens, so the views cannot disagree with each other. +//! +//! Persisted state is the aggregate map itself (schema `version: 1`, +//! this module's own shape — deliberately *not* the Python tracker's +//! file format): lifetime totals plus bounded per-model, per-day +//! (~400 days) and per-hour (48 h) buckets. At ~200 bytes a row the +//! file tops out around 100 KB, so persistence is a single atomic +//! snapshot write (temp → fsync → rename → fsync dir) on a +//! background interval — no write-ahead log, no compaction machinery. +//! +//! # Hot-path contract +//! +//! [`Ledger::record`] performs **no disk I/O and no await**: fold +//! under a mutex, set a dirty flag, return. A background task +//! ([`spawn_flusher`]) and the shutdown path both call +//! [`Ledger::flush`], which serialises writers behind an async gate +//! and does filesystem work on the blocking pool. A failed write +//! re-arms the dirty flag so transient errors retry. +//! +//! # Honesty rules +//! +//! - A failed upstream (non-2xx / transport error) counts in +//! `requests`/`failed` but accrues **no** tokens, USD, or bucket +//! rows — retries can never inflate savings. +//! - Unknown models value at $0 with one WARN +//! (see [`super::pricing`]); no phantom dollars. +//! - Counter math saturates; USD accumulators reject non-finite or +//! negative increments; loading clamps hand-edited negatives to 0. +//! - `per_model` keys come from request bodies (attacker- +//! controlled): length-clamped, cardinality-capped, overflow folds +//! into `"other"`. + +use std::collections::{BTreeMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::pricing; + +/// Persisted-file schema version (this module's own, v1). +const STATS_SCHEMA: u64 = 1; +/// Hourly buckets kept (48 h of hour-resolution history). +const MAX_HOUR_ROWS: usize = 48; +/// Daily buckets kept (~13 months of day-resolution history). +const MAX_DAY_ROWS: usize = 400; +/// Bounded per-model cardinality; overflow folds into [`OVERFLOW_KEY`]. +const MAX_MODEL_ROWS: usize = 100; +const OVERFLOW_KEY: &str = "other"; +/// Model id used until a lane supplies a real one. +const UNKNOWN_MODEL: &str = "unknown"; +/// Recent-request ring served by `/stats/events`. Memory only. +const RECENT_CAP: usize = 100; +/// Per-event ceiling on token counts parsed out of RESPONSE bodies +/// (attacker-controlled if the upstream is compromised): one forged +/// `"input_tokens": u64::MAX` would otherwise price at ~$10^13 and +/// permanently poison every persisted aggregate. 50M tokens is far +/// beyond any real request (largest shipping context ~2M) while +/// keeping honest data untouched. Clamped values WARN — never +/// silently. +const MAX_TOKENS_PER_EVENT: u64 = 50_000_000; +/// Length clamp for client-controlled strings (model / request ids). +const MAX_FIELD_LEN: usize = 120; +/// Background flush cadence. +// ponytail: fixed 10s flush; promote to a flag only if a deployment +// actually needs to tune it. +pub const FLUSH_INTERVAL: Duration = Duration::from_secs(10); + +// ───────────────────────── inputs ───────────────────────── + +/// Provider lane tags — `&'static str` by construction, so the +/// per-provider map is bounded without runtime checks. +pub mod provider { + pub const ANTHROPIC: &str = "anthropic"; + // Same value as the Prometheus cache-hit-rate label + // (`observability::cache_hit_rate_provider::OPENAI_CHAT`) so an + // operator can join /stats against the metrics without a mapping + // table. + pub const OPENAI_CHAT: &str = "openai_chat"; + pub const OPENAI_RESPONSES: &str = "openai_responses"; + pub const BEDROCK: &str = "bedrock"; + pub const VERTEX: &str = "vertex"; +} + +/// Token usage from an upstream response, normalised to Anthropic +/// semantics: `input_tokens` is the *uncached* input portion; cache +/// reads/writes are separate. (OpenAI's `prompt_tokens` includes +/// cached tokens — capture code subtracts before building this.) +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Usage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, +} + +impl Usage { + /// Full prompt context that reached the model. + pub fn total_input(&self) -> u64 { + self.input_tokens + .saturating_add(self.cache_read_tokens) + .saturating_add(self.cache_write_tokens) + } +} + +/// One finished request, ready to fold into the ledger. +#[derive(Debug, Clone)] +pub struct RequestEvent { + pub provider: &'static str, + pub model: String, + pub request_id: String, + /// Input-token estimate before/after compression from the + /// dispatcher; zero when the request wasn't tokenized. + pub tokens_before: u64, + pub tokens_after: u64, + pub usage: Usage, + pub failed: bool, + pub latency_ms: u64, + /// Compression strategies applied (feed detail). + pub transforms: Vec, +} + +impl RequestEvent { + /// Single construction point — clamps client-controlled strings. + pub fn new(provider: &'static str, model: &str, request_id: &str) -> Self { + let model = model.trim(); + Self { + provider, + model: clamp_field(if model.is_empty() { + UNKNOWN_MODEL + } else { + model + }), + request_id: clamp_field(request_id), + tokens_before: 0, + tokens_after: 0, + usage: Usage::default(), + failed: false, + latency_ms: 0, + transforms: Vec::new(), + } + } + + /// Replace the model id, clamped through the same single point + /// as [`RequestEvent::new`]. Empty input is ignored. + /// + /// Recording is deliberately NOT gated on the compression + /// buffer (compression is off by default), so on a pure + /// passthrough request the model is only learned from the + /// response — see [`super::capture`]. + pub fn set_model(&mut self, model: &str) { + let model = model.trim(); + if !model.is_empty() { + self.model = clamp_field(model); + } + } + + /// True while no lane has supplied a real model id. + pub fn model_is_unknown(&self) -> bool { + self.model == UNKNOWN_MODEL + } + + pub fn tokens_saved(&self) -> u64 { + self.tokens_before.saturating_sub(self.tokens_after) + } +} + +// ───────────────────── the one accumulator ───────────────────── + +/// The single accumulator every view is built from. `fold` is the +/// only place request accounting happens. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +pub struct Totals { + #[serde(default)] + pub requests: u64, + #[serde(default)] + pub failed: u64, + /// Requests where compression removed at least one token. + #[serde(default)] + pub compressed: u64, + /// Requests that read from the provider prompt cache + /// (`cache_read_tokens > 0`). + /// + /// The Python tracker's `requests.cached` is + /// `cache_read_tokens > 0 OR served_from_response_cache`. Its + /// second term is Headroom's own response cache, which this proxy + /// doesn't implement — so the two agree today by construction, + /// not by coincidence. Anything adding a response cache here must + /// fold it into this counter too, or the same key will mean + /// different things on the two proxies. + #[serde(default)] + pub cached: u64, + #[serde(default)] + pub tokens_before: u64, + #[serde(default)] + pub tokens_after: u64, + #[serde(default)] + pub tokens_saved: u64, + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cache_read_tokens: u64, + #[serde(default)] + pub cache_write_tokens: u64, + #[serde(default)] + pub compression_savings_usd: f64, + #[serde(default)] + pub cache_savings_usd: f64, + #[serde(default)] + pub input_cost_usd: f64, + /// Spend on generated output. Tracked separately from input + /// because they bill at different rates and the input-side + /// savings levers (compression, caching) never touch it. + #[serde(default)] + pub output_cost_usd: f64, + #[serde(default)] + pub latency_sum_ms: u64, + #[serde(default)] + pub latency_count: u64, +} + +/// USD values computed once per event (so every fold target gets +/// identical dollars). +#[derive(Debug, Default, Clone, Copy)] +struct EventUsd { + compression: f64, + cache: f64, + input_cost: f64, + output_cost: f64, +} + +impl Totals { + fn fold(&mut self, ev: &RequestEvent, usd: EventUsd) { + self.requests = self.requests.saturating_add(1); + // Latency is deliberately accrued for failures too — a + // failing upstream's latency is signal, not savings. + if ev.latency_ms > 0 { + self.latency_sum_ms = self.latency_sum_ms.saturating_add(ev.latency_ms); + self.latency_count = self.latency_count.saturating_add(1); + } + if ev.failed { + // Failures count (and time) — and accrue nothing else: + // no tokens, no USD, no cached/compressed attribution. + self.failed = self.failed.saturating_add(1); + return; + } + let saved = ev.tokens_saved(); + if saved > 0 { + self.compressed = self.compressed.saturating_add(1); + } + if ev.usage.cache_read_tokens > 0 { + self.cached = self.cached.saturating_add(1); + } + self.tokens_before = self.tokens_before.saturating_add(ev.tokens_before); + self.tokens_after = self.tokens_after.saturating_add(ev.tokens_after); + self.tokens_saved = self.tokens_saved.saturating_add(saved); + self.input_tokens = self.input_tokens.saturating_add(ev.usage.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(ev.usage.output_tokens); + self.cache_read_tokens = self + .cache_read_tokens + .saturating_add(ev.usage.cache_read_tokens); + self.cache_write_tokens = self + .cache_write_tokens + .saturating_add(ev.usage.cache_write_tokens); + accumulate_usd(&mut self.compression_savings_usd, usd.compression); + accumulate_usd(&mut self.cache_savings_usd, usd.cache); + accumulate_usd(&mut self.input_cost_usd, usd.input_cost); + accumulate_usd(&mut self.output_cost_usd, usd.output_cost); + } + + /// Clamp USD fields loaded from disk (hand-edits, corruption). + fn sanitize(&mut self) { + for v in [ + &mut self.compression_savings_usd, + &mut self.cache_savings_usd, + &mut self.input_cost_usd, + &mut self.output_cost_usd, + ] { + if !v.is_finite() || *v < 0.0 { + *v = 0.0; + } + } + } + + pub fn savings_usd(&self) -> f64 { + self.compression_savings_usd + self.cache_savings_usd + } + + /// What the operator actually paid: input + output. The headline + /// "spend" number — reporting input alone would omit the pricier + /// half of every request. + pub fn total_cost_usd(&self) -> f64 { + self.input_cost_usd + self.output_cost_usd + } + + /// Effective input volume the operator would have paid for + /// without compression. + fn would_be_input(&self) -> u64 { + self.input_tokens + .saturating_add(self.cache_read_tokens) + .saturating_add(self.cache_write_tokens) + .saturating_add(self.tokens_saved) + } + + /// JSON view. One serializer for every scope, plus derived + /// ratios the dashboard shows. + fn view(&self) -> Value { + let mut v = serde_json::to_value(self).unwrap_or_else(|_| json!({})); + if let Some(obj) = v.as_object_mut() { + obj.insert("savings_usd".into(), json!(self.savings_usd())); + obj.insert("total_cost_usd".into(), json!(self.total_cost_usd())); + let would_be = self.would_be_input(); + obj.insert( + "savings_percent".into(), + json!(percent(self.tokens_saved, would_be)), + ); + obj.insert( + "compression_percent".into(), + json!(percent(self.tokens_saved, self.tokens_before)), + ); + obj.insert( + "cache_hit_percent".into(), + json!(percent( + self.cache_read_tokens, + self.cache_read_tokens + .saturating_add(self.input_tokens) + .saturating_add(self.cache_write_tokens), + )), + ); + obj.insert( + "average_latency_ms".into(), + json!(if self.latency_count > 0 { + self.latency_sum_ms as f64 / self.latency_count as f64 + } else { + 0.0 + }), + ); + } + v + } +} + +fn percent(part: u64, whole: u64) -> f64 { + if whole == 0 { + 0.0 + } else { + (part as f64 / whole as f64) * 100.0 + } +} + +// ───────────────────── persisted state (v1) ───────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedStats { + /// Discriminator + version for future migrations. Defaulted on + /// load (like every other field) because `sanitize()` stamps the + /// current constants over them anyway — a file missing these keys + /// shouldn't be treated as corrupt when its data is loadable. + #[serde(default)] + format: String, + #[serde(default)] + version: u64, + /// First time this ledger ever saw traffic. + #[serde(default)] + first_seen_at: Option, + #[serde(default)] + lifetime: Totals, + #[serde(default)] + per_provider: BTreeMap, + #[serde(default)] + per_model: BTreeMap, + /// `YYYY-MM-DD` → totals, bounded to [`MAX_DAY_ROWS`]. + #[serde(default)] + days: BTreeMap, + /// `YYYY-MM-DDTHH` → totals, bounded to [`MAX_HOUR_ROWS`]. + #[serde(default)] + hours: BTreeMap, +} + +impl Default for PersistedStats { + fn default() -> Self { + Self { + format: "headroom-native-stats".into(), + version: STATS_SCHEMA, + first_seen_at: None, + lifetime: Totals::default(), + per_provider: BTreeMap::new(), + per_model: BTreeMap::new(), + days: BTreeMap::new(), + hours: BTreeMap::new(), + } + } +} + +impl PersistedStats { + fn sanitize(&mut self) { + self.format = "headroom-native-stats".into(); + self.version = STATS_SCHEMA; + self.lifetime.sanitize(); + for m in [ + &mut self.per_provider, + &mut self.per_model, + &mut self.days, + &mut self.hours, + ] { + m.values_mut().for_each(Totals::sanitize); + } + // Persisted per_model keys and first_seen_at are only ever + // self-written (clamped at record time), but a hand-edited + // file is exactly the threat sanitize() exists for: re-clamp + // keys (merging collisions) and drop an unparseable + // first_seen_at rather than echoing arbitrary bytes into the + // unauthenticated /stats payload. + if self.per_model.keys().any(|k| clamp_field(k) != *k) { + let mut reclamped: BTreeMap = BTreeMap::new(); + for (k, v) in std::mem::take(&mut self.per_model) { + merge_totals(reclamped.entry(clamp_field(&k)).or_default(), &v); + } + self.per_model = reclamped; + } + if let Some(ts) = &self.first_seen_at { + if ts.len() > 64 || humantime::parse_rfc3339(ts).is_err() { + self.first_seen_at = None; + } + } + // Bucket keys must be well-formed date labels BEFORE the + // trim below: `trim_oldest` evicts the lexicographically + // first key on the assumption first == oldest, and a + // hand-edited key like "garbage" sorts AFTER every real + // `2026-…` date — it would survive forever while the trim + // silently deleted genuine history in its place. (It would + // also desync the week/month rollups, which validate keys + // asymmetrically at read time.) + self.days.retain(|k, _| is_day_bytes(k.as_bytes())); + self.hours.retain(|k, _| is_hour_key(k)); + trim_oldest(&mut self.days, MAX_DAY_ROWS); + trim_oldest(&mut self.hours, MAX_HOUR_ROWS); + while self.per_model.len() > MAX_MODEL_ROWS + 1 { + let Some(k) = self + .per_model + .keys() + .find(|k| k.as_str() != OVERFLOW_KEY) + .cloned() + else { + break; + }; + self.per_model.remove(&k); + } + } +} + +/// `YYYY-MM-DD` shape check (structure only, not calendar +/// validity — runtime-generated keys are always real dates; this +/// guards hand-edited files). Literal matching, no regex. Byte-wise +/// so a multi-byte hand-edited key can't panic a `&str` slice. +fn is_day_bytes(b: &[u8]) -> bool { + b.len() == 10 + && b.iter().enumerate().all(|(i, c)| match i { + 4 | 7 => *c == b'-', + _ => c.is_ascii_digit(), + }) +} + +/// `YYYY-MM-DDTHH` shape check. Works on bytes throughout: a `&str` +/// slice at a fixed offset would panic on a hand-edited key whose +/// multi-byte character straddles the boundary — the exact input +/// this function exists to reject. +fn is_hour_key(k: &str) -> bool { + let b = k.as_bytes(); + b.len() == 13 + && is_day_bytes(&b[..10]) + && b[10] == b'T' + && b[11..].iter().all(u8::is_ascii_digit) +} + +/// Drop oldest keys until `map` holds at most `cap` rows. Keys are +/// validated date labels (see `sanitize`), so lexicographic "first" +/// is chronologically "oldest". +fn trim_oldest(map: &mut BTreeMap, cap: usize) { + while map.len() > cap { + let Some(k) = map.keys().next().cloned() else { + break; + }; + map.remove(&k); + } +} + +// ───────────────────── recent ring (memory only) ───────────────────── + +#[derive(Debug, Clone, Serialize)] +struct RecentRow { + request_id: String, + timestamp: String, + provider: &'static str, + model: String, + tokens_before: u64, + tokens_after: u64, + tokens_saved: u64, + input_tokens: u64, + output_tokens: u64, + cache_read_tokens: u64, + cache_write_tokens: u64, + compression_savings_usd: f64, + cache_savings_usd: f64, + /// `tokens_saved / tokens_before` — the SAME formula + /// `Totals::view()` publishes as `compression_percent`, and + /// deliberately named to match it. Naming this `savings_percent` + /// would collide with `view()`'s own `savings_percent`, which + /// divides by `would_be_input` (a bigger denominator) — one key, + /// two formulas, across two endpoints. + compression_percent: f64, + latency_ms: u64, + failed: bool, + transforms: Vec, +} + +// ───────────────────────── the ledger ───────────────────────── + +struct Inner { + persisted: PersistedStats, + session: Totals, + session_per_provider: BTreeMap<&'static str, Totals>, + recent: VecDeque, +} + +pub struct Ledger { + inner: Mutex, + dirty: AtomicBool, + path: Option, + /// Serialises the background flusher against the shutdown flush. + flush_gate: tokio::sync::Mutex<()>, + persistence_error: Mutex>, + started_at: SystemTime, +} + +impl Ledger { + /// In-memory ledger (tests, or no usable state dir). Never + /// touches disk. + pub fn in_memory() -> Self { + Self::from_state(PersistedStats::default(), None, None) + } + + /// Load (or initialise) the ledger at `path`. A corrupt file is + /// moved aside (`.corrupt-`) and the ledger starts + /// fresh — loudly, never silently zeroing an intact file. + pub fn load(path: PathBuf) -> Self { + // Guard the unbounded read: the ledger writes ~100 KB files, + // so anything enormous at this path is a misconfiguration + // (wrong --stats-path) — treat it like corruption instead of + // allocating gigabytes at startup. + const MAX_STATE_BYTES: u64 = 64 * 1024 * 1024; + if let Ok(meta) = std::fs::metadata(&path) { + if meta.len() > MAX_STATE_BYTES { + tracing::error!( + event = "stats_state_oversized", + path = %path.display(), + bytes = meta.len(), + "persisted stats file is implausibly large; refusing to load \ + it (check --stats-path) and starting fresh" + ); + return Self::from_state( + PersistedStats::default(), + Some(path), + Some("state file exceeds size cap".into()), + ); + } + } + let (state, error) = match std::fs::read(&path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(mut s) => { + s.sanitize(); + (s, None) + } + Err(e) => { + let aside = path.with_extension(format!("corrupt-{}", epoch_secs())); + let moved = std::fs::rename(&path, &aside).is_ok(); + tracing::error!( + event = "stats_state_corrupt", + path = %path.display(), + preserved = moved, + aside = %aside.display(), + error = %e, + "persisted stats failed to parse; starting fresh" + ); + (PersistedStats::default(), Some(e.to_string())) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (PersistedStats::default(), None), + Err(e) => { + tracing::error!( + event = "stats_state_unreadable", + path = %path.display(), + error = %e, + "persisted stats unreadable; starting fresh (writes will retry)" + ); + (PersistedStats::default(), Some(e.to_string())) + } + }; + Self::from_state(state, Some(path), error) + } + + fn from_state(persisted: PersistedStats, path: Option, error: Option) -> Self { + Self { + inner: Mutex::new(Inner { + persisted, + session: Totals::default(), + session_per_provider: BTreeMap::new(), + recent: VecDeque::with_capacity(RECENT_CAP), + }), + dirty: AtomicBool::new(false), + path, + flush_gate: tokio::sync::Mutex::new(()), + persistence_error: Mutex::new(error), + started_at: SystemTime::now(), + } + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + fn persistence_error(&self) -> std::sync::MutexGuard<'_, Option> { + self.persistence_error + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + // A panic while the lock is held must not take recording or + // /stats down for the rest of the process. + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + // ───────────────────── record ───────────────────── + + /// Fold one finished request into every view. **No I/O, no await.** + pub fn record(&self, mut ev: RequestEvent) { + let at = SystemTime::now(); + clamp_event_tokens(&mut ev); + // Price once; every fold target gets identical dollars. + // Failed requests skip pricing entirely — fold() ignores the + // numbers anyway, and a hostile model id on a failed request + // shouldn't cost a table lookup + warn. + let usd = if ev.failed { + EventUsd::default() + } else { + match pricing::lookup(&ev.model) { + Some(p) => EventUsd { + compression: p.compression_savings_usd(ev.tokens_saved()), + cache: p + .cache_savings_usd(ev.usage.cache_read_tokens, ev.usage.cache_write_tokens), + input_cost: p.input_cost_usd( + // When the response carried no usage block, + // bill the post-compression estimate instead + // so passthrough lanes still show spend. + if ev.usage.total_input() > 0 { + ev.usage.input_tokens + } else { + ev.tokens_after + }, + ev.usage.cache_read_tokens, + ev.usage.cache_write_tokens, + ), + output_cost: p.output_cost_usd(ev.usage.output_tokens), + }, + None => EventUsd::default(), + } + }; + + let iso = to_iso(at); + let day_key = iso[..10].to_string(); // YYYY-MM-DD + let hour_key = iso[..13].to_string(); // YYYY-MM-DDTHH + + let mut inner = self.lock(); + if inner.persisted.first_seen_at.is_none() { + inner.persisted.first_seen_at = Some(iso.clone()); + } + + inner.session.fold(&ev, usd); + inner + .session_per_provider + .entry(ev.provider) + .or_default() + .fold(&ev, usd); + + let p = &mut inner.persisted; + p.lifetime.fold(&ev, usd); + p.per_provider + .entry(ev.provider.to_string()) + .or_default() + .fold(&ev, usd); + let model_key = + if p.per_model.len() >= MAX_MODEL_ROWS && !p.per_model.contains_key(&ev.model) { + OVERFLOW_KEY.to_string() + } else { + ev.model.clone() + }; + p.per_model.entry(model_key).or_default().fold(&ev, usd); + p.days.entry(day_key).or_default().fold(&ev, usd); + p.hours.entry(hour_key).or_default().fold(&ev, usd); + trim_oldest(&mut p.days, MAX_DAY_ROWS); + trim_oldest(&mut p.hours, MAX_HOUR_ROWS); + + // Recent ring — failures included (flagged), savings zeroed. + // + // Deliberately NOT the same rule as the aggregates: this row + // zeroes savings/USD on a failure but keeps the raw token + // counts the response reported. The honesty rule ("a failure + // accrues nothing") is about the numbers an operator sums — + // every aggregate view — and `fold()` enforces it there. This + // ring is a debug feed of what actually happened, where "the + // upstream 500'd after billing us 800 input tokens" is the + // signal you came here for. Nothing sums these rows. + if inner.recent.len() >= RECENT_CAP { + inner.recent.pop_front(); + } + let saved = if ev.failed { 0 } else { ev.tokens_saved() }; + let row = RecentRow { + request_id: ev.request_id, + timestamp: iso, + provider: ev.provider, + model: ev.model, + tokens_before: ev.tokens_before, + tokens_after: ev.tokens_after, + tokens_saved: saved, + input_tokens: ev.usage.input_tokens, + output_tokens: ev.usage.output_tokens, + cache_read_tokens: ev.usage.cache_read_tokens, + cache_write_tokens: ev.usage.cache_write_tokens, + compression_savings_usd: if ev.failed { 0.0 } else { usd.compression }, + cache_savings_usd: if ev.failed { 0.0 } else { usd.cache }, + compression_percent: percent(saved, ev.tokens_before), + latency_ms: ev.latency_ms, + failed: ev.failed, + transforms: ev.transforms, + }; + inner.recent.push_back(row); + drop(inner); + self.dirty.store(true, Ordering::Release); + } + + // ───────────────────── views ───────────────────── + + /// `GET /stats` — the whole snapshot. + /// + /// `summary.api_requests`, `requests.{total,failed,cached}` and + /// `tokens.{saved,proxy_compression_saved}` are a live external + /// contract: headway's unified-stats layer reads exactly those + /// keys from this endpoint today. Everything else is this + /// module's own schema. + /// `include_sensitive` gates the per-request tail and the + /// ledger's filesystem path — see + /// [`super::stats_http::caller_is_local`]. Aggregates are always + /// served; only the rows that name individual requests are held + /// back from network callers. + pub fn stats_payload(&self, include_sensitive: bool) -> Value { + // Copy the raw numbers out under the lock; build the JSON + // tree (thousands of small allocations at full cardinality) + // AFTER releasing it. /stats* is unauthenticated and polled, + // and this mutex is the same one `record()` takes on every + // request — the lock must hold plain memcpys, not tree + // construction. + let (s, session_per_provider, persisted, recent) = { + let inner = self.lock(); + ( + inner.session.clone(), + inner.session_per_provider.clone(), + inner.persisted.clone(), + inner + .recent + .iter() + .rev() + .take(10) + .cloned() + .collect::>(), + ) + }; + let session_providers = totals_map(session_per_provider.iter().map(|(k, t)| (*k, t))); + let lifetime_models = totals_map(persisted.per_model.iter().map(|(k, t)| (k.as_str(), t))); + let lifetime_providers = + totals_map(persisted.per_provider.iter().map(|(k, t)| (k.as_str(), t))); + let recent = rows_json(recent.iter()); + let healthy = self.persistence_error().clone(); + + json!({ + "proxy": "headroom-rust", + "version": env!("CARGO_PKG_VERSION"), + "started_at": to_iso(self.started_at), + "uptime_seconds": self + .started_at + .elapsed() + .map(|d| d.as_secs()) + .unwrap_or(0), + "first_seen_at": persisted.first_seen_at, + // Live headway contract (see doc comment) — session scope. + "summary": { "api_requests": s.requests }, + "requests": { + "total": s.requests, + "failed": s.failed, + "compressed": s.compressed, + "cached": s.cached, + }, + "tokens": { + "input": s.input_tokens, + "output": s.output_tokens, + "cache_read": s.cache_read_tokens, + "cache_write": s.cache_write_tokens, + // Equal on purpose, not a copy-paste: on the Python + // proxy `saved` sums every savings layer (proxy + // compression + CLI filtering + RTK + lean-context) + // while `proxy_compression_saved` is compression + // alone. This proxy HAS only the compression layer, + // so the two are the same number here. Add a second + // savings layer and these must diverge — `saved` + // gains it, `proxy_compression_saved` must not. + "saved": s.tokens_saved, + "proxy_compression_saved": s.tokens_saved, + }, + // The real schema: the same Totals view at every scope. + "session": s.view(), + "session_by_provider": session_providers, + "lifetime": persisted.lifetime.view(), + "lifetime_by_provider": lifetime_providers, + "lifetime_by_model": lifetime_models, + // Per-request rows and the on-disk path are the + // higher-sensitivity tier (the Python proxy gates the + // same data): aggregates for everyone, request-level + // metadata for local callers only. + "recent_requests": if include_sensitive { + json!(recent) + } else { + Value::Null + }, + "persistence": { + "enabled": self.path.is_some(), + "path": if include_sensitive { + json!(self.path().map(|p| p.display().to_string())) + } else { + Value::Null + }, + "healthy": healthy.is_none(), + "error": healthy, + "dirty": self.dirty.load(Ordering::Acquire), + }, + }) + } + + /// `GET /stats/timeseries?bucket=hour|day|week|month`. + /// + /// Hour rows cover the last 48 h; day rows ~13 months; week and + /// month are folded from day rows at read time (weeks start + /// Monday). + pub fn timeseries_payload(&self, bucket: &str) -> Option { + // Same lock discipline as stats_payload: memcpy the rows out, + // fold/build outside the lock. + let source = { + let inner = self.lock(); + match bucket { + "hour" => inner.persisted.hours.clone(), + "day" | "week" | "month" => inner.persisted.days.clone(), + _ => return None, + } + }; + let rows: Vec<(String, Totals)> = match bucket { + "hour" => source + .into_iter() + .map(|(k, t)| (format!("{k}:00:00Z"), t)) + .collect(), + "day" => source.into_iter().collect(), + // week/month fold from day rows (weeks start Monday). + _ => { + let mut folded: BTreeMap = BTreeMap::new(); + for (day, t) in &source { + let Some(label) = (if bucket == "month" { + day.get(..7).map(str::to_string) + } else { + monday_of(day) + }) else { + continue; + }; + merge_totals(folded.entry(label).or_default(), t); + } + folded.into_iter().collect() + } + }; + let points: Vec = rows + .into_iter() + .map(|(label, t)| { + let mut v = t.view(); + if let Some(obj) = v.as_object_mut() { + obj.insert("timestamp".into(), json!(label)); + } + v + }) + .collect(); + Some(json!({ + "bucket": bucket, + "points": points, + "retention": { "hours": MAX_HOUR_ROWS, "days": MAX_DAY_ROWS }, + })) + } + + /// `GET /stats/events?limit=N` — newest first. + pub fn events_payload(&self, limit: usize) -> Value { + let rows: Vec = { + let inner = self.lock(); + inner + .recent + .iter() + .rev() + .take(limit.clamp(1, RECENT_CAP)) + .cloned() + .collect() + }; + json!({ "events": rows_json(rows.iter()), "capacity": RECENT_CAP }) + } + + // ───────────────────── persistence ───────────────────── + + /// Persist dirty state as one atomic snapshot. Serialises + /// callers behind an async gate; filesystem work runs on the + /// blocking pool. A failed write logs, surfaces in + /// `/stats.persistence`, and re-arms the dirty flag. + pub async fn flush(self: &Arc) { + let Some(path) = self.path.clone() else { + return; + }; + let _gate = self.flush_gate.lock().await; + if !self.dirty.swap(false, Ordering::AcqRel) { + return; + } + let payload = { + let inner = self.lock(); + serde_json::to_vec_pretty(&inner.persisted) + }; + let bytes = match payload { + Ok(b) => b, + Err(e) => { + tracing::error!( + event = "stats_serialize_failed", + error = %e, + "stats state failed to serialize; will retry" + ); + self.dirty.store(true, Ordering::Release); + return; + } + }; + let write_path = path.clone(); + let result = tokio::task::spawn_blocking(move || write_atomic(&write_path, &bytes)).await; + let flat = match result { + Ok(inner) => inner, + Err(join_err) => Err(std::io::Error::other(join_err)), + }; + let mut err_slot = self.persistence_error(); + match flat { + Ok(()) => *err_slot = None, + Err(e) => { + tracing::warn!( + event = "stats_flush_failed", + path = %path.display(), + error = %e, + "stats write failed; dirty flag re-armed for retry" + ); + *err_slot = Some(e.to_string()); + self.dirty.store(true, Ordering::Release); + } + } + } + + /// Test hook: unflushed state? + pub fn is_dirty(&self) -> bool { + self.dirty.load(Ordering::Acquire) + } +} + +/// Spawn the background flusher; the shutdown path calls `flush` +/// once more after the server drains. +pub fn spawn_flusher(ledger: Arc) { + if ledger.path.is_none() { + return; + } + tokio::spawn(async move { + let mut tick = tokio::time::interval(FLUSH_INTERVAL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tick.tick().await; + ledger.flush().await; + } + }); +} + +// ───────────────────── helpers ───────────────────── + +/// One serializer for every keyed-Totals map the payloads expose. +fn totals_map<'a>( + it: impl Iterator, +) -> serde_json::Map { + it.map(|(k, t)| (k.to_string(), t.view())).collect() +} + +/// Serialize recent-request rows, newest-first ordering supplied by +/// the caller. +fn rows_json<'a>(it: impl Iterator) -> Vec { + it.map(|r| serde_json::to_value(r).unwrap_or(Value::Null)) + .collect() +} + +fn epoch_secs() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// RFC3339 UTC, seconds precision — `2026-07-16T08:12:34Z`. +/// +/// Clamped at the epoch: `humantime` panics outright on a pre-1970 +/// `SystemTime`, and this runs on the record path for every request +/// and on every `/stats` read. A box whose clock reads 1969 before +/// NTP catches up (container, embedded, dead RTC cell) would +/// otherwise take the proxy down rather than record one odd-looking +/// timestamp — the wrong trade for telemetry. +fn to_iso(t: SystemTime) -> String { + humantime::format_rfc3339_seconds(t.max(SystemTime::UNIX_EPOCH)).to_string() +} + +/// Monday of the week containing a `YYYY-MM-DD` day label. +/// (1970-01-01 was a Thursday; `(days + 3) % 7 == 0` ⇔ Monday.) +fn monday_of(day: &str) -> Option { + let t = humantime::parse_rfc3339(&format!("{day}T00:00:00Z")).ok()?; + let secs = t.duration_since(SystemTime::UNIX_EPOCH).ok()?.as_secs(); + let days = secs / 86_400; + // Saturating: the epoch's own first week (1970-01-01..04) has a + // pre-epoch Monday, and `days - 3` there underflows to ~u64::MAX + // — release builds carry no overflow checks, so it would wrap and + // then panic constructing the SystemTime below. Clamp that one + // partial week to the epoch instead. + let monday = days.saturating_sub((days + 3) % 7); + let start = SystemTime::UNIX_EPOCH + Duration::from_secs(monday * 86_400); + Some(to_iso(start)[..10].to_string()) +} + +fn merge_totals(into: &mut Totals, from: &Totals) { + into.requests = into.requests.saturating_add(from.requests); + into.failed = into.failed.saturating_add(from.failed); + into.compressed = into.compressed.saturating_add(from.compressed); + into.cached = into.cached.saturating_add(from.cached); + into.tokens_before = into.tokens_before.saturating_add(from.tokens_before); + into.tokens_after = into.tokens_after.saturating_add(from.tokens_after); + into.tokens_saved = into.tokens_saved.saturating_add(from.tokens_saved); + into.input_tokens = into.input_tokens.saturating_add(from.input_tokens); + into.output_tokens = into.output_tokens.saturating_add(from.output_tokens); + into.cache_read_tokens = into + .cache_read_tokens + .saturating_add(from.cache_read_tokens); + into.cache_write_tokens = into + .cache_write_tokens + .saturating_add(from.cache_write_tokens); + accumulate_usd( + &mut into.compression_savings_usd, + from.compression_savings_usd, + ); + accumulate_usd(&mut into.cache_savings_usd, from.cache_savings_usd); + accumulate_usd(&mut into.input_cost_usd, from.input_cost_usd); + accumulate_usd(&mut into.output_cost_usd, from.output_cost_usd); + into.latency_sum_ms = into.latency_sum_ms.saturating_add(from.latency_sum_ms); + into.latency_count = into.latency_count.saturating_add(from.latency_count); +} + +/// Enforce [`MAX_TOKENS_PER_EVENT`] on every token field of one +/// event. Response bodies are the trust boundary here — see the +/// constant's doc for the poisoning scenario. +fn clamp_event_tokens(ev: &mut RequestEvent) { + let mut clamped = false; + for v in [ + &mut ev.tokens_before, + &mut ev.tokens_after, + &mut ev.usage.input_tokens, + &mut ev.usage.output_tokens, + &mut ev.usage.cache_read_tokens, + &mut ev.usage.cache_write_tokens, + ] { + if *v > MAX_TOKENS_PER_EVENT { + *v = MAX_TOKENS_PER_EVENT; + clamped = true; + } + } + if clamped { + tracing::warn!( + event = "stats_token_count_clamped", + model = %ev.model, + request_id = %ev.request_id, + cap = MAX_TOKENS_PER_EVENT, + "response reported an implausible token count; clamped to \ + the per-event ceiling before pricing (upstream bug or a \ + hostile response)" + ); + } +} + +/// Clamp a client-controlled string on a char boundary. +fn clamp_field(s: &str) -> String { + // Strip control/format characters first: a model id carrying a + // bidi override (U+202E) or zero-width character would visually + // scramble adjacent dashboard text (Trojan-Source-style spoofing) + // even though it's inserted via textContent. + let cleaned: String = s + .chars() + .filter(|c| { + !c.is_control() + && !('\u{200B}'..='\u{200F}').contains(c) + && !('\u{202A}'..='\u{202E}').contains(c) + && !('\u{2066}'..='\u{2069}').contains(c) + }) + .collect(); + if cleaned.len() <= MAX_FIELD_LEN { + return cleaned; + } + let mut end = MAX_FIELD_LEN; + while !cleaned.is_char_boundary(end) { + end -= 1; + } + cleaned[..end].to_string() +} + +/// Fold a USD increment into an accumulator, rejecting NaN/inf and +/// negative increments, and repairing a bad base. +fn accumulate_usd(base: &mut f64, inc: f64) { + if inc.is_finite() && inc > 0.0 { + *base += inc; + } + if !base.is_finite() || *base < 0.0 { + *base = 0.0; + } +} + +/// Atomic persist: unique temp file → fsync → rename → fsync parent +/// dir. Temp file removed on failure so retries don't litter. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let tmp = path.with_extension(format!("tmp-{}", std::process::id())); + let result = (|| { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + std::fs::rename(&tmp, path)?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + } + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A state file far larger than the ledger could ever write is a + /// misconfigured `--stats-path` (pointed at a log, a dataset, a + /// device). Treat it as corrupt instead of allocating it into + /// memory at startup. + #[test] + fn oversized_state_file_is_refused_instead_of_loaded() { + let dir = tmpdir("oversized"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("stats.json"); + // Sparse file: 65 MiB apparent length, no real disk cost. + let f = std::fs::File::create(&path).unwrap(); + f.set_len(65 * 1024 * 1024).unwrap(); + drop(f); + + let ledger = Ledger::load(path.clone()); + let v = ledger.stats_payload(true); + assert_eq!( + v["lifetime"]["requests"], 0, + "oversized file must not load as state" + ); + assert_eq!( + v["persistence"]["healthy"], false, + "refusing to load is a persistence error, surfaced not swallowed" + ); + // The file is left alone — it is not ours to delete. + assert!(path.exists(), "must not destroy the operator's file"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The epoch's own first week is partial — its Monday precedes + /// 1970. Naive `days - ((days + 3) % 7)` underflows there and + /// panics `/stats/timeseries?bucket=week`, reachable from a + /// hand-edited day key or a box whose clock reads 1970 pre-NTP. + #[test] + fn week_rollup_survives_epoch_boundary_dates() { + for day in ["1970-01-01", "1970-01-02", "1970-01-03", "1970-01-04"] { + assert_eq!( + monday_of(day).as_deref(), + Some("1970-01-01"), + "{day} must clamp to the epoch, not underflow" + ); + } + // Real dates still resolve to their true Monday. + assert_eq!(monday_of("1970-01-05").as_deref(), Some("1970-01-05")); + assert_eq!(monday_of("2026-07-16").as_deref(), Some("2026-07-13")); + } + + #[test] + fn to_iso_clamps_pre_epoch_instead_of_panicking() { + let before = SystemTime::UNIX_EPOCH - Duration::from_secs(86_400); + assert_eq!(&to_iso(before)[..10], "1970-01-01"); + } + + #[test] + fn hour_key_validation_rejects_multibyte_without_panicking() { + // 13 bytes, 12 chars — 'é' straddles byte offset 10. A str + // slice at [..10] would panic here. + assert!(!is_hour_key("AAAAAAAAA\u{e9}BB")); + assert!(is_hour_key("2026-07-16T09")); + assert!(!is_hour_key("2026-07-16x09")); + } + + #[test] + fn sanitize_reclamps_short_keys_carrying_bidi_chars() { + let mut s = PersistedStats::default(); + s.per_model + .insert("gpt-4\u{202E}evil".into(), Totals::default()); + s.sanitize(); + assert!( + s.per_model.contains_key("gpt-4evil"), + "bidi override must be stripped from persisted keys even \ + when the key is under the length cap: {:?}", + s.per_model.keys().collect::>() + ); + } + + /// Output bills at its own (higher) rate. A "spend" number that + /// counts only input understates the bill — on a short-prompt, + /// long-answer turn by most of it. + #[test] + fn output_tokens_are_priced_into_total_spend() { + let ledger = Ledger::in_memory(); + ledger.record(event(PRICED_MODEL)); + let v = ledger.stats_payload(true); + let input = v["session"]["input_cost_usd"].as_f64().unwrap(); + let output = v["session"]["output_cost_usd"].as_f64().unwrap(); + let total = v["session"]["total_cost_usd"].as_f64().unwrap(); + assert!(output > 0.0, "50 output tokens must cost something"); + // sonnet-4-5: $15/M out vs $3/M in — 50 out vs 600 in. + assert!((total - (input + output)).abs() < f64::EPSILON); + assert!(total > input, "total spend must exceed input-only spend"); + } + + #[test] + fn hostile_token_counts_clamp_to_ceiling() { + let ledger = Ledger::in_memory(); + let mut ev = event(PRICED_MODEL); + ev.usage.input_tokens = u64::MAX; + ev.usage.output_tokens = MAX_TOKENS_PER_EVENT + 1; + ev.tokens_before = u64::MAX; + ledger.record(ev); + let payload = ledger.stats_payload(true); + assert_eq!( + payload["tokens"]["input"], MAX_TOKENS_PER_EVENT, + "input clamps to the per-event ceiling" + ); + assert_eq!(payload["tokens"]["output"], MAX_TOKENS_PER_EVENT); + // Pricing ran on the clamped values: finite, sane USD. + let cost = payload["session"]["input_cost_usd"].as_f64().unwrap(); + assert!(cost.is_finite() && cost < 1_000.0, "cost: {cost}"); + // `tokens_before` is clamped too — assert something that + // actually depends on it, or dropping it from + // clamp_event_tokens would leave this test green while + // tokens_saved (and its priced dollars) ran away. Clamped: + // (50M - 600) saved × $3/M ≈ $150. Unclamped it would be ~$5e13. + let compression = payload["session"]["compression_savings_usd"] + .as_f64() + .unwrap(); + assert!( + compression < 1_000.0, + "tokens_before must clamp before pricing: {compression}" + ); + } + + #[test] + fn clamp_field_strips_bidi_and_control_chars() { + assert_eq!( + clamp_field("gpt-4\u{202E}tercés\u{202C}"), + "gpt-4tercés", + "bidi override and pop-directional stripped" + ); + assert_eq!(clamp_field("a\u{200B}b\u{200F}c"), "abc"); + assert_eq!(clamp_field("evil\u{2066}model\u{2069}"), "evilmodel"); + assert_eq!(clamp_field("tab\there\nand\rreturn"), "tabhereandreturn"); + // Oversized input still clamps on a char boundary after cleaning. + let long = "é".repeat(MAX_FIELD_LEN); + let out = clamp_field(&long); + assert!(out.len() <= MAX_FIELD_LEN); + assert!(out.chars().all(|c| c == 'é')); + } + + /// A model that is actually present in the vendored price table + /// ($3/M in, $0.30/M cache read, $3.75/M cache write). Several + /// legacy direct-API ids are absent from the snapshot, so tests + /// asserting real USD must pin one that exists. + const PRICED_MODEL: &str = "claude-sonnet-4-5-20250929"; + + fn event(model: &str) -> RequestEvent { + let mut ev = RequestEvent::new(provider::ANTHROPIC, model, "req-1"); + ev.tokens_before = 1000; + ev.tokens_after = 600; + ev.usage = Usage { + input_tokens: 600, + output_tokens: 50, + cache_read_tokens: 200, + cache_write_tokens: 100, + }; + ev.latency_ms = 42; + ev + } + + fn tmpdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ledger-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + #[test] + fn one_fold_updates_every_view_identically() { + let ledger = Ledger::in_memory(); + ledger.record(event(PRICED_MODEL)); + let stats = ledger.stats_payload(true); + // Session, lifetime, provider, and model views all saw the + // same fold. + for scope in [ + &stats["session"], + &stats["lifetime"], + &stats["session_by_provider"]["anthropic"], + &stats["lifetime_by_provider"]["anthropic"], + &stats["lifetime_by_model"][PRICED_MODEL], + ] { + assert_eq!(scope["requests"], 1, "scope: {scope}"); + assert_eq!(scope["tokens_saved"], 400); + assert_eq!(scope["cache_read_tokens"], 200); + } + // 400 saved input tokens at $3/M. + let comp = stats["session"]["compression_savings_usd"] + .as_f64() + .unwrap(); + assert!((comp - 0.0012).abs() < 1e-9, "comp={comp}"); + // Net cache savings: 200 reads at ($3.0−$0.3)/M discount, + // minus 100 writes at the ($3.75−$3.0)/M premium. + // 0.00054 − 0.000075 = 0.000465. + let cache = stats["session"]["cache_savings_usd"].as_f64().unwrap(); + assert!((cache - 0.000465).abs() < 1e-9, "cache={cache}"); + // Input spend: 600*3e-6 + 200*3e-7 + 100*3.75e-6. + let spend = stats["session"]["input_cost_usd"].as_f64().unwrap(); + assert!((spend - 0.002235).abs() < 1e-9, "spend={spend}"); + // Timeseries picked it up too. + let day = ledger.timeseries_payload("day").unwrap(); + assert_eq!(day["points"][0]["requests"], 1); + let hour = ledger.timeseries_payload("hour").unwrap(); + assert_eq!(hour["points"][0]["tokens_saved"], 400); + } + + #[test] + fn failed_requests_accrue_no_savings_anywhere() { + let ledger = Ledger::in_memory(); + let mut ev = event(PRICED_MODEL); + ev.failed = true; + ledger.record(ev); + let stats = ledger.stats_payload(true); + assert_eq!(stats["requests"]["total"], 1); + assert_eq!(stats["requests"]["failed"], 1); + assert_eq!(stats["tokens"]["saved"], 0); + assert_eq!(stats["session"]["input_cost_usd"], 0.0); + assert_eq!(stats["lifetime"]["tokens_saved"], 0); + let day = ledger.timeseries_payload("day").unwrap(); + assert_eq!(day["points"][0]["failed"], 1); + assert_eq!(day["points"][0]["tokens_saved"], 0); + // Still visible in the feed, flagged. + let events = ledger.events_payload(10); + assert_eq!(events["events"][0]["failed"], true); + assert_eq!(events["events"][0]["tokens_saved"], 0); + } + + #[test] + fn headway_contract_keys_are_served() { + let ledger = Ledger::in_memory(); + // The fixture reads 200 cache tokens → this request IS a + // cache-hit request, so `requests.cached` must say so (it is + // one of the five external-contract keys; a hardcoded 0 here + // once slipped past a vacuous assertion). + ledger.record(event(PRICED_MODEL)); + let stats = ledger.stats_payload(true); + assert_eq!(stats["summary"]["api_requests"], 1); + assert_eq!(stats["requests"]["failed"], 0); + assert_eq!(stats["requests"]["cached"], 1); + assert_eq!(stats["tokens"]["saved"], 400); + assert_eq!(stats["tokens"]["proxy_compression_saved"], 400); + + // And a request with no cache reads must NOT count as cached. + let mut cold = event(PRICED_MODEL); + cold.usage.cache_read_tokens = 0; + ledger.record(cold); + let stats = ledger.stats_payload(true); + assert_eq!(stats["requests"]["total"], 2); + assert_eq!(stats["requests"]["cached"], 1); + } + + #[test] + fn sanitize_drops_malformed_bucket_keys_before_trimming() { + // A hand-edited key like "zz-garbage" sorts AFTER every real + // date, so without validation `trim_oldest` would evict real + // history while the garbage row survived forever. + let dir = tmpdir("badkeys"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("stats.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "format": "headroom-native-stats", + "version": 1, + "days": { + "2026-07-01": {"requests": 3}, + "zz-garbage": {"requests": 999}, + "not a date": {"requests": 999} + }, + "hours": { + "2026-07-01T08": {"requests": 3}, + "2026-07-01T8": {"requests": 999}, + "junk": {"requests": 999} + } + })) + .unwrap(), + ) + .unwrap(); + let ledger = Ledger::load(path); + let days = ledger.timeseries_payload("day").unwrap(); + let points = days["points"].as_array().unwrap().clone(); + assert_eq!(points.len(), 1, "garbage day keys dropped: {points:?}"); + assert_eq!(points[0]["timestamp"], "2026-07-01"); + let hours = ledger.timeseries_payload("hour").unwrap(); + assert_eq!(hours["points"].as_array().unwrap().len(), 1); + // Week/month rollups agree with the day view (they fold from + // the same, now-validated keys). + let week = ledger.timeseries_payload("week").unwrap(); + assert_eq!(week["points"].as_array().unwrap().len(), 1); + let month = ledger.timeseries_payload("month").unwrap(); + assert_eq!(month["points"][0]["requests"], 3); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unknown_model_counts_tokens_but_zero_usd() { + let ledger = Ledger::in_memory(); + ledger.record(event("model-not-in-any-price-table-xyz")); + let stats = ledger.stats_payload(true); + assert_eq!(stats["session"]["tokens_saved"], 400); + assert_eq!(stats["session"]["savings_usd"], 0.0); + assert_eq!(stats["session"]["input_cost_usd"], 0.0); + } + + #[test] + fn per_model_map_is_bounded_with_overflow_fold() { + let ledger = Ledger::in_memory(); + for i in 0..(MAX_MODEL_ROWS + 25) { + ledger.record(event(&format!("hostile-model-{i}"))); + } + let stats = ledger.stats_payload(true); + let models = stats["lifetime_by_model"].as_object().unwrap(); + assert!(models.len() <= MAX_MODEL_ROWS + 1); + assert_eq!(models[OVERFLOW_KEY]["requests"], 25); + } + + #[test] + fn record_does_no_disk_io_until_flush() { + let dir = tmpdir("io"); + let path = dir.join("stats.json"); + let ledger = Arc::new(Ledger::load(path.clone())); + ledger.record(event(PRICED_MODEL)); + assert!(!path.exists(), "record() must not write"); + assert!(ledger.is_dirty()); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(ledger.flush()); + assert!(path.exists(), "flush() persists"); + assert!(!ledger.is_dirty()); + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains("tmp")) + .collect(); + assert!(leftovers.is_empty(), "no temp litter"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn lifetime_survives_restart_and_session_resets() { + let dir = tmpdir("restart"); + let path = dir.join("stats.json"); + let ledger = Arc::new(Ledger::load(path.clone())); + ledger.record(event(PRICED_MODEL)); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(ledger.flush()); + drop(ledger); + + let reloaded = Ledger::load(path); + let stats = reloaded.stats_payload(true); + assert_eq!(stats["lifetime"]["requests"], 1); + assert_eq!(stats["lifetime"]["tokens_saved"], 400); + assert_eq!(stats["session"]["requests"], 0, "session is per-boot"); + // Day buckets survive → historical charts survive restarts. + let day = reloaded.timeseries_payload("day").unwrap(); + assert_eq!(day["points"][0]["requests"], 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn corrupt_file_is_preserved_aside_and_ledger_starts_fresh() { + let dir = tmpdir("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("stats.json"); + std::fs::write(&path, b"{ not json !!").unwrap(); + let ledger = Ledger::load(path.clone()); + assert_eq!(ledger.stats_payload(true)["lifetime"]["requests"], 0); + assert!(!path.exists(), "corrupt file moved aside"); + let preserved = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().contains("corrupt")); + assert!(preserved, "corrupt bytes preserved for inspection"); + assert_eq!(ledger.stats_payload(true)["persistence"]["healthy"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hand_edited_negative_usd_is_clamped_on_load() { + let dir = tmpdir("clamp"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("stats.json"); + std::fs::write( + &path, + br#"{"format":"headroom-native-stats","version":1,"lifetime":{"requests":1,"compression_savings_usd":-5.0}}"#, + ) + .unwrap(); + let ledger = Ledger::load(path); + assert_eq!( + ledger.stats_payload(true)["lifetime"]["compression_savings_usd"], + 0.0 + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hour_and_day_buckets_are_bounded() { + let dir = tmpdir("buckets"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("stats.json"); + // Prebuild a file with too many bucket rows; load must trim + // to the caps, keeping the newest. + let mut days = serde_json::Map::new(); + for i in 0..500 { + days.insert( + format!("2025-{:02}-{:02}", (i / 28) % 12 + 1, i % 28 + 1), + json!({"requests": 1}), + ); + } + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "format": "headroom-native-stats", + "version": 1, + "days": days, + })) + .unwrap(), + ) + .unwrap(); + let ledger = Ledger::load(path); + let day = ledger.timeseries_payload("day").unwrap(); + assert!(day["points"].as_array().unwrap().len() <= MAX_DAY_ROWS); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn week_and_month_fold_from_days() { + let ledger = Ledger::in_memory(); + { + let mut inner = ledger.lock(); + // 2026-07-16 (Thu) and 2026-07-13 (Mon) share a week; + // 2026-06-30 is a different week and month. + for (day, reqs) in [("2026-07-16", 2), ("2026-07-13", 3), ("2026-06-30", 5)] { + let t = inner.persisted.days.entry(day.into()).or_default(); + t.requests = reqs; + } + } + let week = ledger.timeseries_payload("week").unwrap(); + let points = week["points"].as_array().unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[1]["timestamp"], "2026-07-13"); + assert_eq!(points[1]["requests"], 5, "Mon+Thu fold into one week"); + let month = ledger.timeseries_payload("month").unwrap(); + let points = month["points"].as_array().unwrap(); + assert_eq!(points[0]["timestamp"], "2026-06"); + assert_eq!(points[1]["timestamp"], "2026-07"); + assert!(ledger.timeseries_payload("fortnight").is_none()); + } + + #[test] + fn recent_ring_is_bounded_and_newest_first() { + let ledger = Ledger::in_memory(); + for i in 0..(RECENT_CAP + 10) { + let mut ev = event(PRICED_MODEL); + ev.request_id = format!("req-{i}"); + ledger.record(ev); + } + let events = ledger.events_payload(500); + let list = events["events"].as_array().unwrap(); + assert_eq!(list.len(), RECENT_CAP); + assert_eq!(list[0]["request_id"], format!("req-{}", RECENT_CAP + 9)); + } + + #[test] + fn hostile_field_lengths_are_clamped() { + let ledger = Ledger::in_memory(); + let long = "m".repeat(5000); + let mut ev = RequestEvent::new(provider::BEDROCK, &long, &long); + ev.tokens_before = 10; + ev.tokens_after = 5; + ledger.record(ev); + let stats = ledger.stats_payload(true); + let key = stats["lifetime_by_model"] + .as_object() + .unwrap() + .keys() + .next() + .unwrap() + .clone(); + assert!(key.len() <= MAX_FIELD_LEN); + let events = ledger.events_payload(1); + assert!(events["events"][0]["request_id"].as_str().unwrap().len() <= MAX_FIELD_LEN); + } +} diff --git a/crates/headroom-proxy/src/observability/mod.rs b/crates/headroom-proxy/src/observability/mod.rs index d8ad2c7a73..cab6d39960 100644 --- a/crates/headroom-proxy/src/observability/mod.rs +++ b/crates/headroom-proxy/src/observability/mod.rs @@ -39,10 +39,14 @@ //! revisit if multi-process aggregation lands. pub mod cache_hit_rate; +pub mod capture; pub mod compression_ratio; +pub mod ledger; pub mod metric_names; +pub mod pricing; pub mod prometheus; pub mod proxy_metrics; +pub mod stats_http; pub use prometheus::{ handle_metrics, observe_bedrock_invoke_latency, record_bedrock_eventstream_message, diff --git a/crates/headroom-proxy/src/observability/pricing.rs b/crates/headroom-proxy/src/observability/pricing.rs new file mode 100644 index 0000000000..5a53888505 --- /dev/null +++ b/crates/headroom-proxy/src/observability/pricing.rs @@ -0,0 +1,414 @@ +//! Per-token USD pricing — Phase H. Used to value spend and savings. +//! +//! Reads the LiteLLM price table already vendored for +//! [`crate::compression::model_limits`] — one embedded copy, two +//! consumers. No new data file, and no startup network dependency: +//! the table ships inside the binary and parses lazily on first +//! lookup. +//! +//! # Lookup discipline +//! +//! Lookup is exact-match over a small, deterministic candidate list +//! derived from the request's model id (lowercased, then: geo prefix +//! stripped, `:rev` suffix trimmed, provider path segment dropped). +//! We deliberately do NOT substring-scan the table: a short or +//! generic id must miss (and be logged) rather than misprice against +//! whichever stored id happens to contain it. +//! +//! **The unmodified id is always tried first**, because Bedrock +//! cross-region pricing is genuinely region-specific — e.g. +//! `eu.anthropic.claude-3-5-haiku-20241022-v1:0` bills at $0.25/M +//! against `anthropic.claude-3-5-haiku-20241022-v1:0`'s $0.80/M. Any +//! region the table tracks therefore gets its true price. The +//! geo-stripped candidate is only a *fallback* for a (region, model) +//! pair the table does not list, where the base price is a far +//! better estimate than $0 — approximate, but never silently +//! preferred over an exact regional entry. +//! +//! # Coverage +//! +//! The vendored snapshot tracks current model generations plus every +//! Bedrock/Vertex-prefixed id, but it does not carry every legacy +//! direct-API id (`claude-3-5-sonnet-20241022`, for one, is absent +//! while its Bedrock form is present). Those price at $0 with a WARN +//! until the table is refreshed — see the module note below. +//! +//! # Unknown models price at $0 +//! +//! A miss returns `None`; the ledger then records $0 for every USD +//! field and this module logs one WARN naming the model. We +//! deliberately do NOT substitute a blended fallback rate: phantom +//! dollars on a cost dashboard are worse than an obvious zero, and +//! the WARN tells an operator exactly which id to add by refreshing +//! the vendored table (`scripts/refresh_model_limits.sh`). + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use crate::bedrock::vendor::strip_geo_prefix; +use crate::compression::model_limits::VENDORED_JSON; + +/// Per-token USD prices for one model. All fields are $/token +/// (LiteLLM stores them that way natively — no per-million +/// conversion happens here). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ModelPrice { + pub input: f64, + pub output: f64, + pub cache_read: f64, + pub cache_write: f64, +} + +impl ModelPrice { + /// USD saved by removing `tokens_saved` input tokens before they + /// were sent (compression savings). + pub fn compression_savings_usd(&self, tokens_saved: u64) -> f64 { + tokens_saved as f64 * self.input + } + + /// NET USD saved by prompt caching for one request: the read + /// discount (`reads × (input − cache_read)`) minus the write + /// premium (`writes × (cache_write − input)` — cache writes bill + /// ABOVE list price, typically 1.25×). Reporting the gross read + /// discount alone overstates the benefit on write-heavy traffic, + /// which is exactly the phantom-dollars failure mode this module + /// exists to avoid. Floored at $0 per request: a warm-up turn + /// that only writes is an investment, not negative savings — + /// its real cost is already carried by [`Self::input_cost_usd`]. + /// + /// Known bias, stated because it's the honest thing to state: + /// the ledger sums these per-request values, and a floored + /// request contributes 0 rather than its true negative, so + /// `sum(max(0, net))` ≥ `max(0, sum(net))`. Aggregate cache + /// savings therefore skew slightly high. The gap is one write + /// premium per floored request, which in practice means the + /// first turn of a session: once a prefix is cached, later turns + /// read far more than they write and net positive well clear of + /// the floor (100k reads + 5k writes on Sonnet nets +$0.266, vs + /// a $0.004 premium). Fixing it properly means accumulating a + /// signed net and flooring once at the aggregate — worth doing + /// if cache-heavy traffic ever makes the skew material, but not + /// worth a signed accumulator (and a persisted-negative + /// migration) for single-digit thousandths of a cent today. + pub fn cache_savings_usd(&self, cache_read_tokens: u64, cache_write_tokens: u64) -> f64 { + let read_discount = (self.input - self.cache_read).max(0.0); + let write_premium = (self.cache_write - self.input).max(0.0); + let net = + cache_read_tokens as f64 * read_discount - cache_write_tokens as f64 * write_premium; + net.max(0.0) + } + + /// USD spent on generated output for a request. Output bills at + /// its own rate — typically 4-5x input — so a "spend" figure that + /// omits it understates the bill by more than it reports on a + /// short-prompt/long-answer turn. + pub fn output_cost_usd(&self, output_tokens: u64) -> f64 { + output_tokens as f64 * self.output + } + + /// USD actually spent on input for a request, using the cache + /// breakdown when any segment is non-zero (never adding the + /// total on top of the breakdown — that would double-count). + pub fn input_cost_usd( + &self, + uncached_input_tokens: u64, + cache_read_tokens: u64, + cache_write_tokens: u64, + ) -> f64 { + uncached_input_tokens as f64 * self.input + + cache_read_tokens as f64 * self.cache_read + + cache_write_tokens as f64 * self.cache_write + } +} + +static BOOK: OnceLock> = OnceLock::new(); + +/// Bounded once-per-model warn set so an unknown model logs one WARN, +/// not one per request. Capped so attacker-controlled model ids can't +/// grow it without bound; once full, further unknown models simply +/// stop logging (they still price at $0). RwLock so the steady-state +/// path for an already-known-unknown model (every miss after the +/// first) is a shared read, not serialized writes on the record path. +static WARNED: OnceLock>> = OnceLock::new(); +const WARNED_CAP: usize = 256; + +/// Parse the vendored table now instead of on the first recorded +/// request. Called once at startup (main.rs) so the ~1.4 MB JSON +/// parse never stalls tokio workers when a burst of first requests +/// lands together. +pub fn warm() { + let _ = book(); +} + +fn book() -> &'static HashMap { + BOOK.get_or_init(parse_vendored) +} + +fn parse_vendored() -> HashMap { + let raw: serde_json::Value = serde_json::from_str(VENDORED_JSON) + .expect("vendored LiteLLM JSON must parse — same invariant as model_limits"); + let mut out = HashMap::new(); + let Some(obj) = raw.as_object() else { + return out; + }; + for (model_id, spec) in obj { + if model_id == "sample_spec" { + continue; + } + let Some(spec) = spec.as_object() else { + continue; + }; + let input = spec.get("input_cost_per_token").and_then(|v| v.as_f64()); + let output = spec.get("output_cost_per_token").and_then(|v| v.as_f64()); + let (Some(input), Some(output)) = (input, output) else { + continue; + }; + if !input.is_finite() || !output.is_finite() || input < 0.0 || output < 0.0 { + continue; + } + let cache_read = spec + .get("cache_read_input_token_cost") + .and_then(|v| v.as_f64()) + .filter(|c| c.is_finite() && *c >= 0.0) + .unwrap_or(input); + let cache_write = spec + .get("cache_creation_input_token_cost") + .and_then(|v| v.as_f64()) + .filter(|c| c.is_finite() && *c >= 0.0) + .unwrap_or(input); + out.insert( + model_id.to_ascii_lowercase(), + ModelPrice { + input, + output, + cache_read, + cache_write, + }, + ); + } + out +} + +/// Candidate keys tried, in order, for a request model id. Exact +/// matches only — see module doc for why there is no substring scan. +fn candidates(model: &str) -> Vec { + let lower = model.trim().to_ascii_lowercase(); + let mut out = Vec::with_capacity(6); + let mut push = |s: String| { + if !s.is_empty() && !out.contains(&s) { + out.push(s); + } + }; + push(lower.clone()); + // Bedrock cross-region profile: `eu.anthropic.claude…` falls back + // to `anthropic.claude…` when the region itself isn't in the + // table. GEO_PREFIXES (shared with the compression vendor gate) + // covers every geo the vendored table fronts, `us-gov.` included. + let geo = strip_geo_prefix(&lower).to_string(); + push(geo.clone()); + // Bedrock revision suffix: `…-v1:0`. ONLY a numeric suffix is + // trimmed. A non-numeric colon suffix is a routing/tier variant + // (`:exacto`, `:free`, `:nitro`) that can genuinely bill at its + // own rate — the vendored table lists + // `openrouter/z-ai/glm-4.6` at $0.40/M and its `:exacto` variant + // at $0.45/M. Trimming those would hand an uncatalogued variant + // its sibling's price with no warning: a confident wrong number, + // which this module exists to avoid. They miss and WARN instead. + push_revision_trimmed(&mut push, &geo); + push_revision_trimmed(&mut push, &lower); + // Provider-routed ids (`openai/gpt-4o`, `github-copilot/claude…`): + // the bare tail is how LiteLLM stores first-party models. Try the + // tail both verbatim and `:rev`-trimmed, so a combined + // `vendor/model:rev` id can still reach a fully-stripped entry. + if let Some((_, tail)) = lower.rsplit_once('/') { + push(tail.to_string()); + push_revision_trimmed(&mut push, tail); + } + out +} + +/// Push `id` minus a trailing `:` revision, if it has one. +fn push_revision_trimmed(push: &mut impl FnMut(String), id: &str) { + if let Some((head, rev)) = id.rsplit_once(':') { + if !rev.is_empty() && rev.bytes().all(|b| b.is_ascii_digit()) { + push(head.to_string()); + } + } +} + +/// Look up per-token pricing for a model id. `None` means "not in +/// the vendored table" — the caller records $0 for USD fields and +/// this module logs one WARN per distinct model id. +pub fn lookup(model: &str) -> Option { + let table = book(); + for key in candidates(model) { + if let Some(p) = table.get(&key) { + return Some(*p); + } + } + warn_once(model); + None +} + +fn warn_once(model: &str) { + let set = WARNED.get_or_init(|| RwLock::new(std::collections::HashSet::new())); + { + let read = set + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if read.len() >= WARNED_CAP || read.contains(model) { + return; + } + } + let mut write = set + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if write.len() >= WARNED_CAP || !write.insert(model.to_string()) { + return; + } + drop(write); + tracing::warn!( + event = "stats_price_unknown_model", + model = %model, + "model id not in the vendored price table; both spend AND savings \ + for it record as $0 — refresh data/model_prices_and_context_window.json \ + via scripts/refresh_model_limits.sh to add it" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_anthropic_direct_id_prices() { + let p = lookup("claude-sonnet-4-5-20250929").expect("priced"); + assert!(p.input > 0.0 && p.output > p.input); + assert!(p.cache_read < p.input, "cache reads are discounted"); + assert!(p.cache_write > p.input, "cache writes carry a premium"); + } + + /// Bedrock cross-region pricing is region-specific, so an exact + /// regional entry must win over the geo-stripped base id. + #[test] + fn exact_regional_bedrock_entry_wins_over_base() { + let base = lookup("anthropic.claude-3-5-haiku-20241022-v1:0").expect("base priced"); + let eu = lookup("eu.anthropic.claude-3-5-haiku-20241022-v1:0").expect("eu priced"); + assert!( + (base.input - 8e-7).abs() < 1e-12, + "base id prices at list rate, got {}", + base.input + ); + assert!( + (eu.input - 2.5e-7).abs() < 1e-12, + "eu id must use its own regional rate, got {}", + eu.input + ); + assert_ne!( + base, eu, + "geo-stripping must not clobber a tracked regional price" + ); + } + + /// A region the table does NOT list falls back to the base id — + /// approximate, but far better than $0. + /// Every published geo prefix must fall back to the base entry + /// when the table doesn't carry that exact (region, model) pair. + /// Table-driven over GEO_PREFIXES itself, so adding a prefix + /// without its pricing fallback fails here — the `au.`/`jp.`/ + /// `us-gov.` gap this PR fixes was exactly that. + #[test] + fn every_geo_prefix_falls_back_to_base_price() { + const BASE: &str = "anthropic.claude-3-5-haiku-20241022-v1:0"; + let base = lookup(BASE).expect("base priced"); + for geo in crate::bedrock::vendor::GEO_PREFIXES { + let id = format!("{geo}{BASE}"); + if book().get(&id).is_some() { + continue; // table lists this region explicitly — priced on its own terms + } + assert_eq!( + lookup(&id).unwrap_or_else(|| panic!("{id} must fall back")), + base, + "{geo} must strip to the base entry when unlisted" + ); + } + } + + #[test] + fn provider_path_segment_falls_back_to_bare_tail() { + let bare = lookup("gpt-4o").expect("bare id priced"); + let routed = lookup("openai/gpt-4o").expect("routed id priced"); + assert_eq!(bare, routed); + } + + #[test] + fn unknown_model_misses_instead_of_mispricing() { + assert!(lookup("claude").is_none(), "generic id must not match"); + assert!(lookup("totally-unknown-model-xyz").is_none()); + assert!(lookup("").is_none()); + } + + #[test] + fn case_insensitive_lookup() { + assert_eq!( + lookup("GPT-4o").expect("priced"), + lookup("gpt-4o").expect("priced") + ); + } + + /// A combined `vendor/model:rev` id must reach a table entry + /// stored fully stripped — the slash-tail is also tried with its + /// `:rev` suffix trimmed. + #[test] + fn provider_path_and_revision_compose() { + let bare = lookup("gpt-4o").expect("bare id priced"); + let combined = lookup("openai/gpt-4o:2").expect("combined form priced"); + assert_eq!(bare, combined, "provider path + numeric revision compose"); + } + + /// A non-numeric colon suffix is a pricing-relevant variant, not a + /// revision. It must NOT inherit the bare model's rate: the table + /// prices `openrouter/z-ai/glm-4.6` at $0.40/M and `:exacto` at + /// $0.45/M, so borrowing across that boundary invents dollars. + #[test] + fn variant_suffix_never_borrows_the_bare_models_price() { + let bare = lookup("openrouter/z-ai/glm-4.6").expect("bare variant priced"); + let exacto = lookup("openrouter/z-ai/glm-4.6:exacto").expect("listed variant priced"); + assert_ne!(bare.input, exacto.input, "fixture guard: these must differ"); + // An UNLISTED variant must miss loudly, not silently take the + // bare price. + assert!( + lookup("openrouter/z-ai/glm-4.6:priority").is_none(), + "an uncatalogued variant must record $0 + WARN, never a guessed rate" + ); + } + + #[test] + fn usd_helpers_compute_list_price_math() { + let p = ModelPrice { + input: 3e-6, + output: 15e-6, + cache_read: 3e-7, + cache_write: 3.75e-6, + }; + assert!((p.compression_savings_usd(1_000_000) - 3.0).abs() < 1e-9); + // Cache savings = read discount, NET of the write premium. + assert!((p.cache_savings_usd(1_000_000, 0) - 2.7).abs() < 1e-9); + // 1M reads save $2.70; 1M writes cost a $0.75 premium → net $1.95. + assert!((p.cache_savings_usd(1_000_000, 1_000_000) - 1.95).abs() < 1e-9); + // Write-only warm-up floors at $0, never negative savings + // (the premium itself is billed via input_cost_usd). + assert_eq!(p.cache_savings_usd(0, 1_000_000), 0.0); + // Breakdown input cost: uncached + cache_read + cache_write. + let usd = p.input_cost_usd(1_000_000, 1_000_000, 1_000_000); + assert!((usd - (3.0 + 0.3 + 3.75)).abs() < 1e-9); + // Inverted cache pricing clamps to $0, never negative savings. + let inverted = ModelPrice { + input: 1e-6, + output: 1e-6, + cache_read: 2e-6, + cache_write: 1e-6, + }; + assert_eq!(inverted.cache_savings_usd(1000, 0), 0.0); + } +} diff --git a/crates/headroom-proxy/src/observability/stats_http.rs b/crates/headroom-proxy/src/observability/stats_http.rs new file mode 100644 index 0000000000..79946295d9 --- /dev/null +++ b/crates/headroom-proxy/src/observability/stats_http.rs @@ -0,0 +1,216 @@ +//! HTTP surface for the native savings ledger — Phase H: +//! `GET /stats`, `GET /stats/timeseries`, `GET /stats/events`, and +//! the embedded `GET /dashboard` page. +//! +//! All handlers are read-only snapshots over +//! [`super::ledger::Ledger`] — no handler can mutate accounting +//! state. Mounted by [`crate::proxy::build_app`] when +//! `Config::stats` is explicitly enabled; when off (the default), the paths fall through +//! to the catch-all forwarder like any other route. +//! +//! # Exposure: aggregate tier is open, per-request tier is local-only +//! +//! There is no auth here, and `--listen` defaults to `0.0.0.0:8787`, +//! so the payloads are split by sensitivity the same way the Python +//! proxy splits its own `/stats`: +//! +//! - **Aggregates** (spend, tokens, per-model/provider rollups, +//! history) are served to anyone, like the Prometheus `/metrics` +//! endpoint next door — same data class, same stance. +//! - **Per-request rows** (`recent_requests`, all of +//! `/stats/events`) and the ledger's filesystem path are served +//! only to local callers ([`caller_is_local`]). A request id plus a +//! model plus a timestamp is a different tier from a counter, and +//! `/metrics` never exposes it — so "same as /metrics" would be the +//! wrong argument for handing it to the open internet. +//! +//! `--stats=false` removes the routes entirely. Making the local-only +//! tier reachable from a trusted reverse proxy is tracked upstream in +//! headroomlabs-ai/headroom#1959, which is defining that policy for +//! the Python dashboard first; this surface should adopt whatever +//! lands there rather than invent a second scheme. + +use std::net::{IpAddr, SocketAddr}; + +use axum::extract::{ConnectInfo, Query, State}; +use axum::http::header::HOST; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::Value; + +use crate::proxy::AppState; + +/// True when the caller is genuinely local — gates the per-request +/// tier of `/stats` and the whole of `/stats/events`. +/// +/// BOTH conditions are required, mirroring the Python proxy's +/// `loopback_guard`: +/// +/// 1. the TCP peer is a loopback address, and +/// 2. the `Host` header names loopback. +/// +/// (2) is not redundant with (1) — it is the DNS-rebinding defence. A +/// hostile page can rebind its own domain to `127.0.0.1`, so the +/// victim's browser connects to this proxy *from* loopback and (1) +/// passes, while `Host` still reads the attacker's name and their JS +/// reads the response. A real local tool always sends a loopback +/// `Host`; a rebound request does not. +pub(crate) fn caller_is_local(peer: SocketAddr, headers: &HeaderMap) -> bool { + peer.ip().is_loopback() + && headers + .get(HOST) + .and_then(|h| h.to_str().ok()) + .is_some_and(host_is_loopback) +} + +/// `127.0.0.1`, `127.0.0.1:8787`, `[::1]:8787`, `localhost:8787`, … +fn host_is_loopback(host: &str) -> bool { + let host = host.trim(); + // A bare IP with no port must be recognised before any port + // split: `::1` is all colons. + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + let name = match host.strip_prefix('[') { + Some(rest) => match rest.split_once(']') { + Some((inner, _)) => inner, + None => return false, + }, + None => host.rsplit_once(':').map_or(host, |(h, _)| h), + }; + name.eq_ignore_ascii_case("localhost") + || name.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +/// The dashboard page, embedded at compile time so the binary is +/// self-contained (no template dir to deploy, no CDN dependency). +const DASHBOARD_HTML: &str = include_str!("dashboard.html"); + +pub async fn handle_stats( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, +) -> Json { + Json(state.stats.stats_payload(caller_is_local(peer, &headers))) +} + +#[derive(Deserialize)] +pub struct TimeseriesParams { + /// hour | day | week | month (default: day) + bucket: Option, +} + +pub async fn handle_timeseries( + State(state): State, + Query(params): Query, +) -> Response { + let bucket = params.bucket.as_deref().unwrap_or("day"); + match state.stats.timeseries_payload(bucket) { + Some(payload) => Json(payload).into_response(), + None => ( + StatusCode::BAD_REQUEST, + // The Anthropic-style envelope the Bedrock lanes emit + // (`bedrock::invoke::error_response`). Note this is NOT + // universal: `ProxyError` renders plain text, and axum's + // `Query` extractor rejects a malformed `?bucket=` with + // its own plain-text 400 before this handler runs — so a + // client can't assume JSON for every 4xx from this path. + Json(serde_json::json!({ + "error": { + "type": "unknown_bucket", + "message": "bucket must be one of: hour, day, week, month", + } + })), + ) + .into_response(), + } +} + +#[derive(Deserialize)] +pub struct EventsParams { + limit: Option, +} + +/// This endpoint is per-request metadata end to end — there is no +/// aggregate tier to hand a network caller, so the whole thing is +/// gated. 404 rather than 403, matching the Python guard's stance: +/// keep the surface invisible to scanners rather than merely refuse +/// them. +pub async fn handle_events( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Query(params): Query, +) -> Response { + if !caller_is_local(peer, &headers) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + Json(state.stats.events_payload(params.limit.unwrap_or(50))).into_response() +} + +/// `Html` already sets `text/html; charset=utf-8`. +pub async fn handle_dashboard() -> Html<&'static str> { + Html(DASHBOARD_HTML) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers_with_host(host: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(HOST, host.parse().unwrap()); + h + } + + fn peer(addr: &str) -> SocketAddr { + addr.parse().unwrap() + } + + #[test] + fn local_caller_needs_loopback_peer_and_loopback_host() { + for host in [ + "127.0.0.1:8787", + "127.0.0.1", + "localhost:8787", + "LOCALHOST", + "[::1]:8787", + "::1", + "127.5.6.7:8787", // the whole 127/8 block is loopback + ] { + assert!( + caller_is_local(peer("127.0.0.1:5555"), &headers_with_host(host)), + "{host} should read as local" + ); + } + } + + /// The DNS-rebinding case, and the entire reason the `Host` check + /// exists: the victim's browser really is on loopback, so the peer + /// check alone would wave this through while attacker JS reads the + /// per-request feed. + #[test] + fn loopback_peer_with_foreign_host_is_not_local() { + for host in ["attacker.com", "attacker.com:8787", "evil.localhost.dev"] { + assert!( + !caller_is_local(peer("127.0.0.1:5555"), &headers_with_host(host)), + "{host} must not read as local — DNS rebinding" + ); + } + } + + #[test] + fn remote_peer_is_never_local_even_claiming_a_loopback_host() { + assert!(!caller_is_local( + peer("10.1.2.3:5555"), + &headers_with_host("127.0.0.1:8787") + )); + } + + #[test] + fn missing_host_header_fails_closed() { + assert!(!caller_is_local(peer("127.0.0.1:5555"), &HeaderMap::new())); + } +} diff --git a/crates/headroom-proxy/src/proxy.rs b/crates/headroom-proxy/src/proxy.rs index 725f455fd6..4c764acb95 100644 --- a/crates/headroom-proxy/src/proxy.rs +++ b/crates/headroom-proxy/src/proxy.rs @@ -26,6 +26,7 @@ use crate::config::Config; use crate::error::ProxyError; use crate::headers::{build_forward_request_headers, filter_response_headers}; use crate::health::{healthz, healthz_upstream, rollout_status}; +use crate::observability::{capture, ledger}; 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 @@ -80,6 +81,13 @@ pub struct AppState { /// route hits `bearer()`. Tests override via /// [`AppState::with_token_source`]. pub vertex_token_source: Arc, + /// Native savings ledger (one per process). Loaded from + /// `Config::stats_path` when persistence is configured, else + /// in-memory. Handlers record into it after each finished + /// request; `/stats*` and `/dashboard` read from it. Recording + /// call sites gate on `Config::stats` — when stats are disabled + /// this is an inert in-memory ledger. + pub stats: Arc, } /// PR-E6: maximum number of sessions tracked by the drift detector @@ -126,6 +134,15 @@ impl AppState { let vertex_token_source: Arc = Arc::new(crate::vertex::adc::GcpAdcTokenSource::new()); + let stats = if config.stats { + match config.stats_path.clone() { + Some(path) => Arc::new(ledger::Ledger::load(path)), + None => Arc::new(ledger::Ledger::in_memory()), + } + } else { + Arc::new(ledger::Ledger::in_memory()) + }; + Ok(Self { config: Arc::new(config), client, @@ -135,6 +152,7 @@ impl AppState { cache_stabilization::beta_sticky::BETA_TRACKER_CAPACITY, ), vertex_token_source, + stats, }) } @@ -161,6 +179,36 @@ impl AppState { } } +/// Per-route middleware for `/metrics`, attached only when +/// `--metrics-require-loopback` is enabled. Rejects any scrape whose +/// peer address is not loopback (`127.0.0.0/8` / `::1`) with +/// `403 Forbidden`, keeping operational metrics off the wire on a +/// non-loopback bind. Relies on the server being served with +/// `ConnectInfo` (see `main.rs`); requests synthesised +/// without connect info (e.g. `oneshot` in unit tests) would fail the +/// `ConnectInfo` extractor — which is one reason the gate defaults off. +async fn require_metrics_loopback( + ConnectInfo(peer): ConnectInfo, + req: Request, + next: axum::middleware::Next, +) -> axum::response::Response { + if peer.ip().is_loopback() { + next.run(req).await + } else { + tracing::warn!( + event = "metrics_scrape_rejected_non_loopback", + peer = %peer, + "rejected /metrics scrape from non-loopback client \ + (--metrics-require-loopback is enabled)" + ); + ( + StatusCode::FORBIDDEN, + "metrics endpoint restricted to loopback", + ) + .into_response() + } +} + /// Build the axum app. `/healthz` and `/healthz/upstream` are intercepted; /// everything else hits the catch-all forwarder. WebSocket upgrades are /// handled inside the catch-all handler when an `Upgrade: websocket` header @@ -169,15 +217,39 @@ 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 - // scrapes (`prometheus`'s registry uses internal locking). - // Mounted unconditionally because it has no dependencies on - // any feature flag; an operator who doesn't want it scraped - // simply firewalls the path. - .route("/metrics", get(crate::observability::handle_metrics)) + .route("/rollout/status", get(rollout_status)); + + // Native savings stats + dashboard. Served locally (never + // tunnelled upstream) so the Rust proxy is self-sufficient for + // savings observability. Gated on `--stats` so an operator can + // restore pure-passthrough behaviour for these paths. + if state.config.stats { + router = router + .route( + "/stats", + get(crate::observability::stats_http::handle_stats), + ) + .route( + "/stats/timeseries", + get(crate::observability::stats_http::handle_timeseries), + ) + .route( + "/stats/events", + get(crate::observability::stats_http::handle_events), + ) + .route( + "/dashboard", + get(crate::observability::stats_http::handle_dashboard), + ); + } else { + tracing::warn!( + event = "stats_disabled", + "native savings stats disabled by --stats=false; \ + /stats*, /dashboard fall through to the upstream forwarder \ + and no request telemetry is recorded" + ); + } + router = router // PR-C2: explicit POST route for /v1/chat/completions. The // handler buffers the body and re-injects it into // `forward_http`, which runs the OpenAI live-zone gate @@ -212,6 +284,27 @@ pub fn build_app(state: AppState) -> Router { post(crate::vertex::handle_vertex_predict_dispatch), ); + // PR-D3: Prometheus scrape endpoint. Renders the global registry in + // text format. The handler is stateless — no `AppState` needed — and + // idempotent across concurrent scrapes (`prometheus`'s registry uses + // internal locking). Mounted as its own sub-router so the optional + // loopback gate can wrap ONLY this route. `/metrics` exposes + // operational detail (token counts, per-session cache-hit rates, + // rate-limit gauges); when `--metrics-require-loopback` is set, + // `require_metrics_loopback` rejects non-loopback peers with 403 — + // defense-in-depth alongside firewalling the path. Default off, + // preserving the previous "mounted unconditionally; operator + // firewalls the path" behaviour. + let metrics_router: Router = { + let base = Router::new().route("/metrics", get(crate::observability::handle_metrics)); + if state.config.metrics_require_loopback { + base.route_layer(axum::middleware::from_fn(require_metrics_loopback)) + } else { + base + } + }; + router = router.merge(metrics_router); + // PR-D1: native AWS Bedrock InvokeModel route. Mounts only when // `enable_bedrock_native` is on (default). The handler runs the // live-zone compressor over Anthropic-shape bodies, signs with @@ -571,10 +664,10 @@ pub(crate) async fn forward_http( // controls what the buffered branch does (currently both `Off` // and `LiveZone` passthrough); Phase B will branch on it inside // `compress_anthropic_request`. - let should_intercept = state.config.compression - && method == axum::http::Method::POST + let is_llm_json_post = method == axum::http::Method::POST && compression::is_compressible_path(uri.path()) && is_application_json(req.headers()); + let should_intercept = state.config.compression && is_llm_json_post; // PR-E6: capture a header snapshot BEFORE the body is consumed so // the drift detector can derive a per-session key from @@ -592,6 +685,33 @@ pub(crate) async fn forward_http( let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes()) .map_err(|e| ProxyError::InvalidHeader(e.to_string()))?; + // Savings ledger. Deliberately NOT gated on `should_intercept`: + // spend/caching stats are worth answering on a pure passthrough + // deployment too (compression savings are truthfully 0 there), + // so the gate is the LLM-endpoint shape alone and the model id + // is learned from the response when the request didn't carry it. + // Finalized exactly once, when the response is fully observed. + let should_record = state.config.stats && is_llm_json_post; + let mut pending_stats = should_record + .then(|| compression::classify_compressible_path(uri.path())) + .flatten() + .map(|endpoint| { + let provider = match endpoint { + compression::CompressibleEndpoint::AnthropicMessages => ledger::provider::ANTHROPIC, + compression::CompressibleEndpoint::OpenAiChatCompletions => { + ledger::provider::OPENAI_CHAT + } + compression::CompressibleEndpoint::OpenAiResponses => { + ledger::provider::OPENAI_RESPONSES + } + }; + capture::PendingRecord::new( + state.stats.clone(), + ledger::RequestEvent::new(provider, "", &request_id), + start, + ) + }); + let upstream_resp = if should_intercept { // Buffer up to `compression_max_body_bytes`. If the body // exceeds this, the body is already partially consumed and @@ -617,6 +737,9 @@ pub(crate) async fn forward_http( "compression: Content-Length exceeds buffer limit; \ returning 413 without consuming body" ); + // A proxy-side rejection is still an outcome: + // record it as failed rather than dropping it. + capture::finalize_rejected(pending_stats.take()); return Err(ProxyError::PayloadTooLarge(format!( "request Content-Length {len} exceeds compression \ buffer limit ({max} bytes)" @@ -634,6 +757,7 @@ pub(crate) async fn forward_http( "compression: body exceeds buffer limit; failing loudly (cannot \ resume streaming once the body has been partially consumed)" ); + capture::finalize_rejected(pending_stats.take()); return Err(ProxyError::PayloadTooLarge(format!( "request body exceeds compression buffer limit ({max} bytes): {e}" ))); @@ -692,7 +816,14 @@ pub(crate) async fn forward_http( // enough that a canonical-bytes hash would compare apples // to oranges. The volatile detector handles its own // shape-dispatch via `ApiKind::from_endpoint`. + let mut stats_model: Option = None; if let Ok(parsed) = serde_json::from_slice::(&buffered) { + // Model id for the savings ledger — read from the same + // sanitized bytes the dispatcher compresses. + stats_model = parsed + .get("model") + .and_then(|m| m.as_str()) + .map(str::to_owned); // PR-E5: volatile-content detector. Emits one WARN per // finding (capped at 10) for content that busts cache // (timestamps, UUIDs, ID-named fields). @@ -764,6 +895,10 @@ pub(crate) async fn forward_http( } } } + // Compression-side numbers for the savings ledger, captured + // out of the Compressed arm below (the outcome is consumed + // by the body-selection match). + let mut stats_compression: (u64, u64, Vec) = (0, 0, Vec::new()); let outcome = match endpoint { compression::CompressibleEndpoint::AnthropicMessages => { // PR-E3: thread the F1-classified auth_mode into the @@ -849,6 +984,11 @@ pub(crate) async fn forward_http( markers_inserted, per_strategy_tokens, } => { + stats_compression = ( + tokens_before as u64, + tokens_after as u64, + strategies_applied.iter().map(|s| s.to_string()).collect(), + ); tracing::info!( request_id = %request_id, path = %path_for_log, @@ -969,34 +1109,71 @@ pub(crate) async fn forward_http( compression::CompressibleEndpoint::AnthropicMessages => body_to_send, }; + if let Some(p) = pending_stats.as_mut() { + // Request-side model (already `[1m]`-sanitized above, so + // it matches what upstream bills) wins over the response + // echo; the compression numbers are only knowable here. + if let Some(m) = stats_model.as_deref() { + p.set_model(m); + } + let (before, after, transforms) = stats_compression; + p.set_compression(before, after, transforms); + } + // Forward the (Phase A: identical) buffered bytes. reqwest // sets its own Content-Length from the body bytes — the // existing `build_forward_request_headers` already strips // the client-supplied Content-Length for us. - state + match state .client .request(reqwest_method, upstream_url.clone()) .headers(outgoing_headers) .body(body_to_send) .send() - .await? + .await + { + Ok(resp) => resp, + Err(e) => { + // Transport/connect failures are request outcomes + // too — record them so outages are visible in /stats. + capture::finalize_rejected(pending_stats.take()); + return Err(e.into()); + } + } } else { // Pure streaming path — the original passthrough behaviour. let body_stream = TryStreamExt::map_err(req.into_body().into_data_stream(), std::io::Error::other); let reqwest_body = reqwest::Body::wrap_stream(body_stream); - state + match state .client .request(reqwest_method, upstream_url.clone()) .headers(outgoing_headers) .body(reqwest_body) .send() - .await? + .await + { + Ok(resp) => resp, + Err(e) => { + // Same contract as the buffered arm. + capture::finalize_rejected(pending_stats.take()); + return Err(e.into()); + } + } }; let upstream_status = upstream_resp.status(); let status = StatusCode::from_u16(upstream_status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + // Savings honesty rule: a non-2xx upstream is a failed request — + // it counts, but accrues no savings (the ledger enforces this on + // the failed flag). + if !upstream_status.is_success() { + if let Some(p) = pending_stats.as_mut() { + p.mark_failed(); + } + } + // PR-A8 / P5-57: capture the upstream request id BEFORE we move // `upstream_resp.headers()` into the response filter. Anthropic // emits `request-id` (lowercase, no `x-`); OpenAI emits @@ -1112,8 +1289,32 @@ pub(crate) async fn forward_http( let parser_tx = if !matches!(sse_kind, SseStreamKind::None) { let (tx, rx) = tokio::sync::mpsc::channel::(SSE_PARSER_QUEUE_DEPTH); let rid_for_parser = request_id.clone(); - tokio::spawn(run_sse_state_machine(sse_kind, rx, rid_for_parser)); + // Deferred recording: the pending stats event rides into the + // state machine and is finalized when the stream closes, so + // output/cache token counts come from the real SSE usage + // frames instead of recording zero at header time. + tokio::spawn(run_sse_state_machine( + sse_kind, + rx, + rid_for_parser, + pending_stats.take(), + )); Some(tx) + } else if let Some(p) = pending_stats.take() { + // Non-SSE response on a recorded LLM path: tee the JSON body + // into a bounded buffer and parse usage when it completes. + let shape = match SseStreamKind::for_request_path(&path_for_log) { + SseStreamKind::OpenAiChat => capture::ResponseShape::OpenAiChat, + SseStreamKind::OpenAiResponses => capture::ResponseShape::OpenAiResponses, + // Anthropic (/v1/messages). Note: /v1/responses with the + // streaming state machine disabled still matches the + // OpenAiResponses arm above — for_request_path classifies + // by path, independent of the enable flag. An SSE body + // teed here simply fails the JSON parse and records + // compression-side numbers alone. + _ => capture::ResponseShape::Anthropic, + }; + Some(capture::spawn_json_usage_capture(shape, p)) } else { None }; @@ -1178,11 +1379,9 @@ pub(crate) async fn forward_http( } /// Bound on the in-flight queue between the byte-passthrough and the -/// SSE state-machine task. Picked so that under steady-state streaming -/// load (~5 events/100ms typical) the parser is never blocked on -/// queue space, yet a stalled parser can't grow memory unboundedly. -/// Tunable via `proxy.toml` if a deployment finds this insufficient. -const SSE_PARSER_QUEUE_DEPTH: usize = 256; +/// SSE state-machine task — see [`TEE_QUEUE_DEPTH`] for the sizing +/// rationale it shares with every other telemetry tee. +use crate::observability::capture::TEE_QUEUE_DEPTH as SSE_PARSER_QUEUE_DEPTH; /// Which provider's state machine should run on this stream. Picked /// from the *request* path because the response content-type @@ -1338,10 +1537,15 @@ fn maybe_inject_openai_prompt_cache_key( /// Drive the per-provider state machine over a stream of byte chunks. /// Lives in its own task; the byte path never waits on it. +/// +/// `pending_stats` (when the savings ledger is recording this +/// request) is finalized here — after the stream closes — with the +/// usage the state machine accumulated. async fn run_sse_state_machine( kind: SseStreamKind, mut rx: tokio::sync::mpsc::Receiver, request_id: String, + pending_stats: Option, ) { use crate::sse::framing::SseFramer; @@ -1412,6 +1616,20 @@ async fn run_sse_state_machine( blocks = state.blocks.len(), "sse stream closed" ); + if let Some(mut p) = pending_stats { + if let Some(model) = state.model.as_deref() { + p.set_model_if_unknown(model); + } + // A 200 OK stream can still fail in-band: Anthropic + // emits `{"type":"error"}` mid-stream (e.g. overloaded + // during generation) and the state machine lands in + // `Errored`. That is not a billable success — record + // it as failed so retries can't inflate savings. + if matches!(state.status, crate::sse::anthropic::StreamStatus::Errored) { + p.mark_failed(); + } + p.finalize(capture::usage_from_anthropic_state(&state)); + } } SseStreamKind::OpenAiChat => { let mut state = crate::sse::openai_chat::ChunkState::new(); @@ -1515,6 +1733,19 @@ async fn run_sse_state_machine( has_usage = state.usage.is_some(), "sse stream closed" ); + if let Some(mut p) = pending_stats { + if let Some(model) = state.model.as_deref() { + p.set_model_if_unknown(model); + } + let usage = state + .usage + .as_ref() + .and_then(|u| { + capture::usage_from_usage_block(capture::ResponseShape::OpenAiChat, u) + }) + .unwrap_or_default(); + p.finalize(usage); + } } SseStreamKind::OpenAiResponses => { let mut state = crate::sse::openai_responses::ResponseState::new(); @@ -1646,8 +1877,33 @@ async fn run_sse_state_machine( incomplete_reason = state.incomplete_reason.as_deref().unwrap_or(""), "sse stream closed" ); + if let Some(mut p) = pending_stats { + if let Some(model) = state.model.as_deref() { + p.set_model_if_unknown(model); + } + // `response.failed` / `response.incomplete` are not + // billable successes — count them as failures even + // though the HTTP status was 2xx. + if matches!(state.terminal_status(), Some("failed") | Some("incomplete")) { + p.mark_failed(); + } + let usage = state + .usage + .as_ref() + .and_then(|u| { + capture::usage_from_usage_block(capture::ResponseShape::OpenAiResponses, u) + }) + .unwrap_or_default(); + p.finalize(usage); + } + } + SseStreamKind::None => { + if let Some(p) = pending_stats { + // No parser for this stream — record what the + // request side knew (compression estimates only). + p.finalize(ledger::Usage::default()); + } } - SseStreamKind::None => {} } } @@ -1697,6 +1953,47 @@ mod tests { assert_eq!(out.as_str(), "http://up:8080/"); } + // `require_metrics_loopback` gate, driven directly via `oneshot` + // with a manually-injected `ConnectInfo` so BOTH branches are + // covered — including the non-loopback 403 path the real-server + // integration test can't reach from a loopback-only client. + fn metrics_gate_router() -> Router { + Router::new() + .route("/metrics", get(|| async { "metrics-body" })) + .route_layer(axum::middleware::from_fn(require_metrics_loopback)) + } + + async fn scrape_status_from(peer: &str) -> StatusCode { + use tower::util::ServiceExt; + let mut req = Request::builder() + .method("GET") + .uri("/metrics") + .body(Body::empty()) + .unwrap(); + let addr: SocketAddr = peer.parse().unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + metrics_gate_router().oneshot(req).await.unwrap().status() + } + + #[tokio::test] + async fn metrics_gate_allows_ipv4_loopback() { + assert_eq!(scrape_status_from("127.0.0.1:5555").await, StatusCode::OK); + } + + #[tokio::test] + async fn metrics_gate_allows_ipv6_loopback() { + assert_eq!(scrape_status_from("[::1]:5555").await, StatusCode::OK); + } + + #[tokio::test] + async fn metrics_gate_rejects_non_loopback_with_403() { + // 203.0.113.0/24 is TEST-NET-3 (RFC 5737) — never loopback. + assert_eq!( + scrape_status_from("203.0.113.7:5555").await, + StatusCode::FORBIDDEN + ); + } + #[test] fn url_build_refuses_to_escape_base_path() { // `url::Url::set_path` would resolve this to `/tenant-b/v1/messages`. diff --git a/crates/headroom-proxy/src/sse/framing.rs b/crates/headroom-proxy/src/sse/framing.rs index bd337c60b9..889a9d1d66 100644 --- a/crates/headroom-proxy/src/sse/framing.rs +++ b/crates/headroom-proxy/src/sse/framing.rs @@ -124,6 +124,35 @@ impl SseFramer { if chunk.is_empty() { return; } + // Cap the accumulator: a stream that never emits a blank-line + // terminator (a non-SSE body routed here, or a hostile + // upstream) would otherwise grow this buffer for the life of + // the stream. A real SSE event is a few KB; 4 MiB is orders + // of magnitude past any legitimate frame. + // + // On overflow we drop the buffer and the triggering chunk, so + // parsing resumes mid-stream at an arbitrary byte offset. + // Usually that garbage forms a prefix-less line the parser + // ignores; it CAN also resume on a syntactically valid + // `data:` line and yield one truncated event. That is + // acceptable only because every consumer is a telemetry tee + // off the byte path (`SseFramer` is never on the client + // path — see the three callers), so the worst case is a wrong + // usage number for one stream, bounded further by the + // ledger's own per-event token ceiling. Client bytes are + // never touched. Revisit this cap before framing anything + // client-facing with it. + const MAX_BUFFERED_BYTES: usize = 4 * 1024 * 1024; + if self.buf.len() + chunk.len() > MAX_BUFFERED_BYTES { + tracing::warn!( + event = "sse_framer_buffer_overflow", + buffered = self.buf.len(), + "no event terminator within 4 MiB; discarding buffered \ + bytes (telemetry-only consumer — client stream unaffected)" + ); + self.buf.clear(); + return; + } self.buf.extend_from_slice(chunk); } diff --git a/crates/headroom-proxy/src/vertex/mod.rs b/crates/headroom-proxy/src/vertex/mod.rs index 2302fce3cf..f69fb5166f 100644 --- a/crates/headroom-proxy/src/vertex/mod.rs +++ b/crates/headroom-proxy/src/vertex/mod.rs @@ -90,6 +90,7 @@ use axum::http::{HeaderMap, Method, StatusCode, Uri}; use axum::response::Response; use std::net::SocketAddr; +use crate::observability::{capture, ledger}; use crate::proxy::AppState; /// Single axum handler mounted at the @@ -157,6 +158,15 @@ pub async fn handle_vertex_predict_dispatch( segment = %model_action, "vertex path final segment missing `:verb` separator" ); + // A proxy-side rejection is still an outcome for this + // request. The model id is whatever the caller sent (it + // is what failed to parse); the ledger clamps it. + capture::finalize_rejected(capture::start_pending( + &state, + ledger::provider::VERTEX, + &model_action, + &request_id, + )); return Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("vertex path: bad model_action")) @@ -173,6 +183,12 @@ pub async fn handle_vertex_predict_dispatch( verb = %verb_str, "vertex path verb not recognized; only rawPredict / streamRawPredict are supported" ); + capture::finalize_rejected(capture::start_pending( + &state, + ledger::provider::VERTEX, + model_id, + &request_id, + )); return Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("vertex: unknown verb")) diff --git a/crates/headroom-proxy/src/vertex/raw_predict.rs b/crates/headroom-proxy/src/vertex/raw_predict.rs index 5059b2a44f..d675fe364e 100644 --- a/crates/headroom-proxy/src/vertex/raw_predict.rs +++ b/crates/headroom-proxy/src/vertex/raw_predict.rs @@ -36,6 +36,8 @@ use std::net::SocketAddr; use crate::compression; use crate::headers::{build_forward_request_headers, filter_response_headers}; +use crate::observability::capture::TEE_QUEUE_DEPTH as VERTEX_SSE_QUEUE_DEPTH; +use crate::observability::{capture, ledger}; use crate::proxy::AppState; use crate::vertex::{adc::TokenSourceError, envelope, VertexVerb}; @@ -76,6 +78,13 @@ pub(crate) async fn forward_vertex_request( ) -> Response { let path_for_log = uri.path().to_string(); + // Constructed at handler ENTRY so every exit — including + // proxy-side rejections (oversized body, envelope mismatch, ADC + // failure) — records exactly once. An ADC outage must show in + // /stats as a failure spike, not zero traffic. + let mut pending_stats = + capture::start_pending(&state, ledger::provider::VERTEX, &ctx.model_id, &request_id); + // ─── 1. BUFFER BODY ──────────────────────────────────────────────── let max = state.config.compression_max_body_bytes as usize; let buffered = match to_bytes(body, max).await { @@ -89,6 +98,7 @@ pub(crate) async fn forward_vertex_request( error = %e, "vertex request body exceeds compression buffer limit; failing loudly" ); + capture::finalize_rejected(pending_stats.take()); return error_response( StatusCode::PAYLOAD_TOO_LARGE, "request body exceeds buffer limit", @@ -122,6 +132,7 @@ pub(crate) async fn forward_vertex_request( error = %e, "vertex envelope did not match expected shape; rejecting with 400" ); + capture::finalize_rejected(pending_stats.take()); return error_response(StatusCode::BAD_REQUEST, "vertex envelope invalid"); } } @@ -133,6 +144,7 @@ pub(crate) async fn forward_vertex_request( // The dispatcher uses RawValue-based surgery so `anthropic_version` // (and any other non-`messages` top-level field) round-trips // byte-equal. Compression off → buffered bytes used unchanged. + let mut stats_compression: (u64, u64, Vec) = (0, 0, Vec::new()); let body_to_send = if state.config.compression { // PR-E3: Vertex uses GCP ADC bearer-token auth downstream, not // Anthropic credentials, so the PAYG/OAuth/subscription @@ -177,6 +189,11 @@ pub(crate) async fn forward_vertex_request( markers = markers_inserted.len(), "vertex live-zone compression applied" ); + stats_compression = ( + tokens_before as u64, + tokens_after as u64, + strategies_applied.iter().map(|s| s.to_string()).collect(), + ); body } compression::Outcome::Passthrough { reason } => { @@ -201,6 +218,11 @@ pub(crate) async fn forward_vertex_request( buffered }; + if let Some(p) = pending_stats.as_mut() { + let (before, after, transforms) = stats_compression; + p.set_compression(before, after, transforms); + } + // ─── 4. RESOLVE BEARER TOKEN ─────────────────────────────────────── let bearer = match state.vertex_token_source.bearer().await { Ok(t) => t, @@ -221,6 +243,7 @@ pub(crate) async fn forward_vertex_request( TokenSourceError::ProviderInit(_) => StatusCode::BAD_GATEWAY, TokenSourceError::Fetch(_) => StatusCode::BAD_GATEWAY, }; + capture::finalize_rejected(pending_stats.take()); return error_response(status, "vertex ADC token fetch failed"); } }; @@ -248,6 +271,7 @@ pub(crate) async fn forward_vertex_request( error = %e, "could not construct vertex upstream URL" ); + capture::finalize_rejected(pending_stats.take()); return error_response(StatusCode::BAD_GATEWAY, "vertex upstream URL build failed"); } }; @@ -293,6 +317,7 @@ pub(crate) async fn forward_vertex_request( error = %e, "ADC bearer token contained invalid header bytes; refusing to forward" ); + capture::finalize_rejected(pending_stats.take()); return error_response(StatusCode::BAD_GATEWAY, "vertex auth header build failed"); } } @@ -308,9 +333,11 @@ pub(crate) async fn forward_vertex_request( error = %e, "could not convert axum method to reqwest method" ); + capture::finalize_rejected(pending_stats.take()); return error_response(StatusCode::BAD_REQUEST, "vertex method invalid"); } }; + let upstream_resp = match state .client .request(reqwest_method, upstream_url.clone()) @@ -321,6 +348,9 @@ pub(crate) async fn forward_vertex_request( { Ok(r) => r, Err(e) => { + if let Some(p) = pending_stats.take() { + p.finalize_failed(); + } tracing::warn!( event = "vertex_upstream_error", request_id = %request_id, @@ -335,6 +365,11 @@ pub(crate) async fn forward_vertex_request( // ─── 8. STREAM RESPONSE ──────────────────────────────────────────── let upstream_status = upstream_resp.status(); let status = StatusCode::from_u16(upstream_status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + if !upstream_status.is_success() { + if let Some(p) = pending_stats.as_mut() { + p.mark_failed(); + } + } let resp_headers = filter_response_headers(upstream_resp.headers()); // PR-C1 reuse: when `attach_sse_tee` is set AND the upstream @@ -355,7 +390,11 @@ pub(crate) async fn forward_vertex_request( let parser_tx = if attach_sse_tee && is_sse { let (tx, rx) = tokio::sync::mpsc::channel::(VERTEX_SSE_QUEUE_DEPTH); let rid = request_id.clone(); - tokio::spawn(run_anthropic_sse_state_machine(rx, rid)); + tokio::spawn(run_anthropic_sse_state_machine( + rx, + rid, + pending_stats.take(), + )); tracing::info!( event = "vertex_sse_tee_engaged", request_id = %request_id, @@ -363,7 +402,12 @@ pub(crate) async fn forward_vertex_request( ); Some(tx) } else { - None + // Non-SSE (rawPredict, or a JSON error on the streaming + // verb): tee the body into a bounded buffer and parse the + // Anthropic-shape usage block when it completes. + pending_stats + .take() + .map(|p| capture::spawn_json_usage_capture(capture::ResponseShape::Anthropic, p)) }; use futures_util::StreamExt as _; @@ -434,13 +478,6 @@ fn error_response(status: StatusCode, msg: &'static str) -> Response { .expect("static error response") } -/// Bound on the in-flight queue between the byte-passthrough and the -/// SSE state-machine task. Mirrors the -/// `crate::proxy::SSE_PARSER_QUEUE_DEPTH` rationale (256 events ≈ 5 -/// seconds of typical Anthropic streaming under the per-100ms event -/// rate; keeps memory bounded even if the parser stalls). -const VERTEX_SSE_QUEUE_DEPTH: usize = 256; - /// Drive the Anthropic SSE state machine over a stream of byte /// chunks. Lives in its own spawned task; the byte path is fed via a /// best-effort tee from [`forward_vertex_request`] and never blocks @@ -448,6 +485,7 @@ const VERTEX_SSE_QUEUE_DEPTH: usize = 256; async fn run_anthropic_sse_state_machine( mut rx: tokio::sync::mpsc::Receiver, request_id: String, + pending_stats: Option, ) { use crate::sse::framing::SseFramer; let mut framer = SseFramer::new(); @@ -487,4 +525,12 @@ async fn run_anthropic_sse_state_machine( blocks = state.blocks.len(), "vertex sse stream closed" ); + if let Some(mut p) = pending_stats { + // In-band `{"type":"error"}` on a 200 OK stream is not a + // billable success (same rule as the direct Anthropic lane). + if matches!(state.status, crate::sse::anthropic::StreamStatus::Errored) { + p.mark_failed(); + } + p.finalize(capture::usage_from_anthropic_state(&state)); + } } diff --git a/crates/headroom-proxy/tests/common/mod.rs b/crates/headroom-proxy/tests/common/mod.rs index 67ddaeb532..711c073c6f 100644 --- a/crates/headroom-proxy/tests/common/mod.rs +++ b/crates/headroom-proxy/tests/common/mod.rs @@ -111,3 +111,17 @@ pub fn install_static_token_source(mut state: AppState, bearer: &str) -> AppStat pub fn _config_ref() -> Arc { Arc::new(Config::for_test(Url::parse("http://127.0.0.1:1").unwrap())) } + +/// Static, obviously-fake AWS credentials for SigV4-signing tests. +/// Shared by every Bedrock test binary — the mock upstream never +/// validates the signature, only that one is present. +#[allow(dead_code)] +pub fn test_credentials() -> aws_credential_types::Credentials { + aws_credential_types::Credentials::new( + "AKIAEXAMPLEAKIDFORTEST", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ) +} diff --git a/crates/headroom-proxy/tests/integration_bedrock_authmode.rs b/crates/headroom-proxy/tests/integration_bedrock_authmode.rs index 6fa0568895..8b2cb2eba8 100644 --- a/crates/headroom-proxy/tests/integration_bedrock_authmode.rs +++ b/crates/headroom-proxy/tests/integration_bedrock_authmode.rs @@ -20,14 +20,13 @@ mod common; -use aws_credential_types::Credentials; use axum::body::Body; use axum::extract::{Extension, State}; use axum::http::StatusCode; use axum::routing::post; use axum::Router; use bytes::Bytes; -use common::start_proxy_with_state; +use common::{start_proxy_with_state, test_credentials}; use headroom_core::auth_mode::AuthMode; use headroom_proxy::AppState; use serde_json::{json, Value}; @@ -41,16 +40,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const TEST_MODEL: &str = "anthropic.claude-3-haiku-20240307-v1:0"; -fn test_credentials() -> Credentials { - Credentials::new( - "AKIAEXAMPLEAKIDFORTEST", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - None, - "test", - ) -} - #[derive(Default, Clone, Debug)] struct CapturedRequest { body: Option>, diff --git a/crates/headroom-proxy/tests/integration_bedrock_invoke.rs b/crates/headroom-proxy/tests/integration_bedrock_invoke.rs index 449c0c832c..c7c584de30 100644 --- a/crates/headroom-proxy/tests/integration_bedrock_invoke.rs +++ b/crates/headroom-proxy/tests/integration_bedrock_invoke.rs @@ -35,8 +35,7 @@ mod common; -use aws_credential_types::Credentials; -use common::start_proxy_with_state; +use common::{start_proxy_with_state, test_credentials}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::sync::{Arc, Mutex}; @@ -132,16 +131,6 @@ fn assert_byte_equal_sha256(inbound: &[u8], received: &[u8]) { ); } -fn test_credentials() -> Credentials { - Credentials::new( - "AKIAEXAMPLEAKIDFORTEST", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - None, - "test", - ) -} - /// Boot a proxy pointed at the wiremock upstream as the Bedrock /// endpoint. The fake-upstream URL goes into `bedrock_endpoint`; /// the regular `upstream` field is set to a sentinel because the diff --git a/crates/headroom-proxy/tests/integration_bedrock_metrics.rs b/crates/headroom-proxy/tests/integration_bedrock_metrics.rs index 0bdb791804..f2b1593385 100644 --- a/crates/headroom-proxy/tests/integration_bedrock_metrics.rs +++ b/crates/headroom-proxy/tests/integration_bedrock_metrics.rs @@ -23,9 +23,8 @@ mod common; -use aws_credential_types::Credentials; use bytes::{Bytes, BytesMut}; -use common::start_proxy_with_state; +use common::{start_proxy_with_state, test_credentials}; use headroom_proxy::bedrock::MessageBuilder; use serde_json::json; use url::Url; @@ -49,16 +48,6 @@ const TEST_REGION_LATENCY: &str = "us-test-latency-1"; const TEST_REGION_EVENTSTREAM: &str = "us-test-eventstream-1"; const TEST_REGION_SCRAPE: &str = "us-test-scrape-1"; -fn test_credentials() -> Credentials { - Credentials::new( - "AKIAEXAMPLEAKIDFORTEST", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - None, - "test", - ) -} - async fn bedrock_proxy_with_region( upstream: &MockServer, region: &str, diff --git a/crates/headroom-proxy/tests/integration_bedrock_streaming.rs b/crates/headroom-proxy/tests/integration_bedrock_streaming.rs index 5bb4ba0996..6ffebadf71 100644 --- a/crates/headroom-proxy/tests/integration_bedrock_streaming.rs +++ b/crates/headroom-proxy/tests/integration_bedrock_streaming.rs @@ -22,9 +22,8 @@ mod common; -use aws_credential_types::Credentials; use bytes::{Bytes, BytesMut}; -use common::start_proxy_with_state; +use common::{start_proxy_with_state, test_credentials}; use headroom_proxy::bedrock::{ parse_eventstream, CrcValidation, EventStreamParser, HeaderValue, MessageBuilder, ParseError, }; @@ -36,16 +35,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const TEST_MODEL: &str = "anthropic.claude-3-haiku-20240307-v1:0"; -fn test_credentials() -> Credentials { - Credentials::new( - "AKIAEXAMPLEAKIDFORTEST", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - None, - "test", - ) -} - /// Synthesise a minimal Bedrock-shape Anthropic stream as binary /// EventStream bytes. The exact JSON payload mirrors what real /// Bedrock emits for a 2-token completion. @@ -577,3 +566,77 @@ proptest! { } } } + +/// Regression (caught in review of the first stats PR, #1057; the fix +/// never landed with it): in SSE-translation mode, an unknown +/// `:event-type` (`TranslateOutcome::Skip`) arriving in the same +/// upstream chunk as known messages must not strand the known +/// messages in the parser buffer. The pre-fix drain shape pulled a +/// FRESH upstream chunk after a Skip, so when the Skip'd frame sat in +/// the final chunk, every complete message buffered behind it was +/// dropped and the client's stream ended early. +#[tokio::test] +async fn skip_in_final_chunk_does_not_strand_buffered_frames() { + // One chunk, three messages: known → unknown → known. + let mut buf = bytes::BytesMut::new(); + let frame = |event_type: &str, payload: serde_json::Value| { + MessageBuilder::new() + .header_string(":event-type", event_type) + .header_string(":content-type", "application/json") + .header_string(":message-type", "event") + .payload(Bytes::from(serde_json::to_string(&payload).unwrap())) + .build() + }; + buf.extend_from_slice(&frame( + "chunk", + serde_json::json!({"type": "message_start", + "message": {"id": "m1", "usage": {"input_tokens": 3}}}), + )); + buf.extend_from_slice(&frame( + "somethingNewFromAws", + serde_json::json!({"novel": true}), + )); + buf.extend_from_slice(&frame("chunk", serde_json::json!({"type": "message_stop"}))); + let body = buf.freeze(); + + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke-with-response-stream", + )) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/vnd.amazon.eventstream") + .set_body_raw(body.to_vec(), "application/vnd.amazon.eventstream"), + ) + .mount(&upstream) + .await; + let proxy = bedrock_proxy(&upstream, |_| {}).await; + + let resp = reqwest::Client::new() + .post(format!( + "{}/model/anthropic.claude-3-haiku-20240307-v1:0/invoke-with-response-stream", + proxy.url() + )) + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .json(&serde_json::json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let text = resp.text().await.unwrap(); + assert!( + text.contains("message_start"), + "frame before the Skip must arrive: {text}" + ); + assert!( + text.contains("message_stop"), + "frame buffered BEHIND the Skip'd message must still arrive \ + (pre-fix behaviour dropped it): {text}" + ); +} diff --git a/crates/headroom-proxy/tests/integration_metrics_loopback.rs b/crates/headroom-proxy/tests/integration_metrics_loopback.rs new file mode 100644 index 0000000000..57d9274411 --- /dev/null +++ b/crates/headroom-proxy/tests/integration_metrics_loopback.rs @@ -0,0 +1,59 @@ +//! `/metrics` loopback-gate integration coverage. +//! +//! `--metrics-require-loopback` (`Config::metrics_require_loopback`) +//! restricts the Prometheus scrape endpoint to loopback peers as +//! defense-in-depth for non-loopback binds. The shared harness serves +//! the app over a real `127.0.0.1` listener with +//! `ConnectInfo`, so a reqwest client here is itself a +//! loopback peer: with the gate ON the scrape must STILL succeed (200), +//! proving the middleware is wired correctly and never breaks the +//! legitimate local-scrape path. The non-loopback rejection (403) path +//! can't be driven from a loopback-only test client; it is enforced by +//! the middleware's `peer.ip().is_loopback()` check in +//! `crate::proxy::require_metrics_loopback`. + +mod common; + +use common::start_proxy_with; + +/// Gate ON: a loopback client is still allowed through, so the scrape +/// returns 200 with the usual Prometheus descriptor lines. +#[tokio::test] +async fn metrics_gate_allows_loopback_scrape() { + // Upstream is never contacted — `/metrics` is served locally. + let proxy = start_proxy_with("http://127.0.0.1:1", |cfg| { + cfg.metrics_require_loopback = true; + }) + .await; + + let resp = reqwest::Client::new() + .get(format!("{}/metrics", proxy.url())) + .send() + .await + .expect("metrics scrape"); + + assert_eq!( + resp.status(), + 200, + "loopback client must still pass the metrics loopback gate" + ); + let body = resp.text().await.unwrap(); + assert!( + body.contains("# HELP") || body.contains("# TYPE"), + "scrape body should carry Prometheus metric descriptors" + ); +} + +/// Gate OFF (default): unchanged behaviour — the scrape is served. +#[tokio::test] +async fn metrics_default_open_serves_scrape() { + let proxy = start_proxy_with("http://127.0.0.1:1", |_| {}).await; + + let resp = reqwest::Client::new() + .get(format!("{}/metrics", proxy.url())) + .send() + .await + .expect("metrics scrape"); + + assert_eq!(resp.status(), 200); +} diff --git a/crates/headroom-proxy/tests/integration_stats.rs b/crates/headroom-proxy/tests/integration_stats.rs new file mode 100644 index 0000000000..37d9ba6bb3 --- /dev/null +++ b/crates/headroom-proxy/tests/integration_stats.rs @@ -0,0 +1,911 @@ +//! Integration tests for the native savings stats surface: +//! `/stats`, `/stats/timeseries`, `/stats/events`, `/dashboard`, +//! and the per-lane recording hooks (deferred SSE finalization, +//! non-streaming JSON usage capture, failed-request accounting, +//! Bedrock camelCase usage). + +mod common; + +use std::time::Duration; + +use common::{ + install_static_token_source, start_proxy_with, start_proxy_with_state, test_credentials, +}; +use headroom_proxy::bedrock::MessageBuilder; +use serde_json::{json, Value}; +use url::Url; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// The capture tasks finalize asynchronously after the client sees +/// the response — poll /stats until the predicate holds. +async fn wait_for_stats(base: &str, pred: F) -> Value +where + F: Fn(&Value) -> bool, +{ + let client = reqwest::Client::new(); + for _ in 0..100 { + let v: Value = client + .get(format!("{base}/stats")) + .send() + .await + .expect("GET /stats") + .json() + .await + .expect("stats json"); + if pred(&v) { + return v; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("stats predicate not satisfied within 5s"); +} + +/// POST a JSON body the way every recording test does. +async fn post_json(url: String, body: &Value) -> reqwest::Response { + reqwest::Client::new() + .post(url) + .header("content-type", "application/json") + .json(body) + .send() + .await + .expect("POST") +} + +/// One binary EventStream frame with the standard event headers. +fn event_frame(event_type: &str, payload: &Value) -> bytes::Bytes { + MessageBuilder::new() + .header_string(":message-type", "event") + .header_string(":event-type", event_type) + .header_string(":content-type", "application/json") + .payload(payload.to_string().into()) + .build() +} + +#[tokio::test] +async fn stats_endpoints_serve_locally_and_validate_input() { + let upstream = MockServer::start().await; + let proxy = start_proxy_with(&upstream.uri(), |_| {}).await; + let client = reqwest::Client::new(); + + let stats: Value = client + .get(format!("{}/stats", proxy.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(stats["proxy"], "headroom-rust"); + assert_eq!(stats["requests"]["total"], 0); + // headway's unified-stats reader contract. + assert!(stats["summary"]["api_requests"].is_u64()); + assert!(stats["tokens"]["proxy_compression_saved"].is_u64()); + assert!(stats["requests"]["cached"].is_u64()); + + let ts: Value = client + .get(format!("{}/stats/timeseries?bucket=day", proxy.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(ts["bucket"], "day"); + let bad = client + .get(format!("{}/stats/timeseries?bucket=fortnight", proxy.url())) + .send() + .await + .unwrap(); + assert_eq!(bad.status(), 400); + + let events: Value = client + .get(format!("{}/stats/events", proxy.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(events["events"].as_array().unwrap().is_empty()); + + let dash = client + .get(format!("{}/dashboard", proxy.url())) + .send() + .await + .unwrap(); + assert_eq!(dash.status(), 200); + let ct = dash + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert!(ct.starts_with("text/html"), "content-type: {ct}"); + let body = dash.text().await.unwrap(); + assert!(body.contains("Headroom Proxy — Savings")); + // Self-contained page: no network resource references of any + // kind. The only URLs allowed are XML namespaces and data: URIs. + for needle in [ + "src=\"http", + "src=\"//", + "href=\"http", + "href=\"//", + "@import", + "fonts.googleapis", + "cdn.", + ] { + assert!( + !body.contains(needle), + "dashboard must be self-contained; found {needle:?}" + ); + } +} + +#[tokio::test] +async fn stats_disabled_falls_through_to_upstream() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/stats")) + .respond_with(ResponseTemplate::new(200).set_body_string("python-proxy-stats")) + .mount(&upstream) + .await; + let proxy = start_proxy_with(&upstream.uri(), |c| c.stats = false).await; + + let body = reqwest::Client::new() + .get(format!("{}/stats", proxy.url())) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert_eq!( + body, "python-proxy-stats", + "with --stats=false the path must tunnel upstream (pre-feature behaviour)" + ); +} + +#[tokio::test] +async fn anthropic_non_streaming_response_records_usage() { + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "hi"}], + "usage": { + "input_tokens": 100, + "output_tokens": 25, + "cache_read_input_tokens": 40, + "cache_creation_input_tokens": 10 + } + }))) + .mount(&upstream) + .await; + // compression=true engages the buffered arm (mode stays Off — + // recording must work in pure-passthrough deployments too). + let proxy = start_proxy_with(&upstream.uri(), |c| c.compression = true).await; + + let resp = post_json( + format!("{}/v1/messages", proxy.url()), + &json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 32, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); // drain so the capture tee closes + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["tokens"]["input"], 100); + assert_eq!(stats["tokens"]["output"], 25); + assert_eq!(stats["tokens"]["cache_read"], 40); + assert_eq!(stats["tokens"]["cache_write"], 10); + assert_eq!(stats["requests"]["failed"], 0); + // 40 cache-read tokens ⇒ this request counts as a cache hit. + assert_eq!(stats["requests"]["cached"], 1); + assert_eq!(stats["session_by_provider"]["anthropic"]["requests"], 1); + // 40 cache reads on sonnet pricing → non-zero cache savings. + assert!(stats["session"]["cache_savings_usd"].as_f64().unwrap() > 0.0); + assert!(stats["session"]["input_cost_usd"].as_f64().unwrap() > 0.0); + // Model attribution landed. + assert_eq!( + stats["lifetime_by_model"]["claude-sonnet-4-5-20250929"]["requests"], + 1 + ); + // Timeseries picked it up. + let ts: Value = reqwest::Client::new() + .get(format!("{}/stats/timeseries?bucket=hour", proxy.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(ts["points"][0]["requests"], 1); +} + +/// Recording must not depend on the compression master switch — +/// it is OFF by default, and spend/cache observability is the whole +/// point of the feature on a passthrough deployment. The model is +/// learned from the response there (the request body is never +/// buffered), and compression savings are legitimately 0. +#[tokio::test] +async fn records_with_compression_off_using_model_from_response() { + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "hi"}], + "usage": {"input_tokens": 200, "output_tokens": 10, + "cache_read_input_tokens": 80} + }))) + .mount(&upstream) + .await; + // Config::for_test defaults compression to false — same as the + // shipped binary's default. + let proxy = start_proxy_with(&upstream.uri(), |c| { + assert!(!c.compression, "guard: this test covers compression OFF"); + }) + .await; + + let resp = post_json( + format!("{}/v1/messages", proxy.url()), + &json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 32, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["tokens"]["input"], 200); + assert_eq!(stats["tokens"]["cache_read"], 80); + assert_eq!(stats["tokens"]["saved"], 0, "no compression ran → 0 saved"); + // Model came from the response envelope, so spend is priced. + assert_eq!( + stats["lifetime_by_model"]["claude-sonnet-4-5-20250929"]["requests"], 1, + "model must be learned from the response when the request isn't buffered" + ); + assert!(stats["session"]["input_cost_usd"].as_f64().unwrap() > 0.0); + assert!(stats["session"]["cache_savings_usd"].as_f64().unwrap() > 0.0); +} + +#[tokio::test] +async fn anthropic_sse_stream_records_deferred_usage() { + let upstream = MockServer::start().await; + let sse_body = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":", + "{\"input_tokens\":50,\"output_tokens\":1,\"cache_read_input_tokens\":12}}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":", + "{\"type\":\"text_delta\",\"text\":\"hey\"}}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},", + "\"usage\":{\"output_tokens\":30}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_body.as_bytes().to_vec(), "text/event-stream"), + ) + .mount(&upstream) + .await; + let proxy = start_proxy_with(&upstream.uri(), |c| c.compression = true).await; + + let resp = post_json( + format!("{}/v1/messages", proxy.url()), + &json!({ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 32, + "stream": true, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + // Deferred recording: counts come from the stream's usage + // frames, observed only after the stream closed. + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["tokens"]["input"], 50); + assert_eq!( + stats["tokens"]["output"], 30, + "output from message_delta, not 0" + ); + assert_eq!(stats["tokens"]["cache_read"], 12); +} + +#[tokio::test] +async fn failed_upstream_counts_but_accrues_no_savings() { + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(529).set_body_json(json!({ + "type": "error", + "error": {"type": "overloaded_error", "message": "overloaded"} + }))) + .mount(&upstream) + .await; + let proxy = start_proxy_with(&upstream.uri(), |c| c.compression = true).await; + + let resp = post_json( + format!("{}/v1/messages", proxy.url()), + &json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 32, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 529); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["requests"]["failed"], 1); + assert_eq!(stats["tokens"]["saved"], 0); + assert_eq!(stats["session"]["savings_usd"], 0.0); + assert_eq!(stats["session"]["input_cost_usd"], 0.0); + // Visible in the feed, flagged. + let events: Value = reqwest::Client::new() + .get(format!("{}/stats/events?limit=5", proxy.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(events["events"][0]["failed"], true); + assert_eq!(events["events"][0]["tokens_saved"], 0); +} + +#[tokio::test] +async fn openai_chat_response_normalises_cached_tokens() { + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-1", + "object": "chat.completion", + "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop"}], + "usage": { + "prompt_tokens": 90, + "completion_tokens": 12, + "prompt_tokens_details": {"cached_tokens": 60} + } + }))) + .mount(&upstream) + .await; + let proxy = start_proxy_with(&upstream.uri(), |c| c.compression = true).await; + + let resp = post_json( + format!("{}/v1/chat/completions", proxy.url()), + &json!({ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + // prompt_tokens INCLUDES cached — the ledger stores the split. + assert_eq!(stats["tokens"]["input"], 30); + assert_eq!(stats["tokens"]["cache_read"], 60); + assert_eq!(stats["tokens"]["output"], 12); + // Provider label matches the Prometheus vocabulary + // (`metric_names`): the chat lane is `openai_chat`, not `openai`. + assert_eq!(stats["session_by_provider"]["openai_chat"]["requests"], 1); +} + +#[tokio::test] +async fn bedrock_converse_sync_records_camelcase_usage() { + let upstream = MockServer::start().await; + let model_id = "anthropic.claude-3-5-haiku-20241022-v1:0"; + Mock::given(method("POST")) + .and(path(format!("/model/{model_id}/converse"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output": {"message": {"role": "assistant", + "content": [{"text": "hello"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 9, "outputTokens": 4, + "cacheReadInputTokens": 2, "cacheWriteInputTokens": 1} + }))) + .mount(&upstream) + .await; + + let endpoint: Url = upstream.uri().parse().unwrap(); + let proxy = start_proxy_with_state( + &upstream.uri(), + |c| c.bedrock_endpoint = Some(endpoint), + |s| s.with_bedrock_credentials(test_credentials()), + ) + .await; + + let resp = post_json( + format!("{}/model/{model_id}/converse", proxy.url()), + &json!({ + "messages": [{"role": "user", "content": [{"text": "hello"}]}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["session_by_provider"]["bedrock"]["requests"], 1); + assert_eq!(stats["tokens"]["input"], 9); + assert_eq!(stats["tokens"]["output"], 4); + assert_eq!(stats["tokens"]["cache_read"], 2); + assert_eq!(stats["tokens"]["cache_write"], 1); + // Bedrock model ids price via the vendored table (cache reads + // discounted vs list input price). + assert!(stats["session"]["cache_savings_usd"].as_f64().unwrap() > 0.0); + assert_eq!(stats["lifetime_by_model"][model_id]["requests"], 1); +} + +#[tokio::test] +async fn bedrock_invoke_sync_records_snake_case_usage() { + let upstream = MockServer::start().await; + let model_id = "anthropic.claude-3-5-haiku-20241022-v1:0"; + Mock::given(method("POST")) + .and(path(format!("/model/{model_id}/invoke"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", "type": "message", "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "content": [{"type": "text", "text": "ok"}], + "usage": {"input_tokens": 12, "output_tokens": 4} + }))) + .mount(&upstream) + .await; + let endpoint: Url = upstream.uri().parse().unwrap(); + let proxy = start_proxy_with_state( + &upstream.uri(), + |c| c.bedrock_endpoint = Some(endpoint), + |s| s.with_bedrock_credentials(test_credentials()), + ) + .await; + + let resp = post_json( + format!("{}/model/{model_id}/invoke", proxy.url()), + &json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["tokens"]["input"], 12); + assert_eq!(stats["tokens"]["output"], 4); + assert_eq!(stats["session_by_provider"]["bedrock"]["requests"], 1); + // The request-side model id (with region/revision) wins over the + // response echo's bare id. + assert_eq!(stats["lifetime_by_model"][model_id]["requests"], 1); +} + +/// The passthrough lane nothing else parses: binary EventStream +/// (`Accept: application/vnd.amazon.eventstream`), with InvokeModel's +/// base64-wrapped Anthropic events. Usage must come out of the tee's +/// telemetry parser while the client receives byte-equal frames. +#[tokio::test] +async fn bedrock_eventstream_passthrough_records_usage() { + use base64::Engine as _; + let b64 = |v: &Value| base64::engine::general_purpose::STANDARD.encode(v.to_string()); + let frame = |inner: &Value| event_frame("chunk", &json!({"bytes": b64(inner)})); + let mut body_bytes: Vec = Vec::new(); + for inner in [ + json!({"type": "message_start", + "message": {"id": "msg_1", "usage": {"input_tokens": 21, "output_tokens": 1, + "cache_read_input_tokens": 7}}}), + json!({"type": "content_block_delta", "index": 0, + "delta": {"type": "text_delta", "text": "ok"}}), + json!({"type": "message_delta", "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 9}}), + json!({"type": "message_stop"}), + ] { + body_bytes.extend_from_slice(&frame(&inner)); + } + + let upstream = MockServer::start().await; + let model_id = "anthropic.claude-3-5-haiku-20241022-v1:0"; + Mock::given(method("POST")) + .and(path(format!( + "/model/{model_id}/invoke-with-response-stream" + ))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/vnd.amazon.eventstream") + // Cloned: the assertion below compares the client's + // bytes against these exact frames. + .set_body_raw(body_bytes.clone(), "application/vnd.amazon.eventstream"), + ) + .mount(&upstream) + .await; + let endpoint: Url = upstream.uri().parse().unwrap(); + let proxy = start_proxy_with_state( + &upstream.uri(), + |c| c.bedrock_endpoint = Some(endpoint), + |s| s.with_bedrock_credentials(test_credentials()), + ) + .await; + + let resp = reqwest::Client::new() + .post(format!( + "{}/model/{model_id}/invoke-with-response-stream", + proxy.url() + )) + .header("content-type", "application/json") + // EventStream passthrough mode — the client wants raw frames. + .header("accept", "application/vnd.amazon.eventstream") + .json(&json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let echoed = resp.bytes().await.unwrap(); + // The whole claim of this lane is that telemetry rides alongside + // the bytes without touching them. Assert that literally: the + // client's stream must be byte-identical to what the upstream + // sent, tee or no tee. `!is_empty()` would pass a mangled body. + assert_eq!( + echoed.as_ref(), + body_bytes.as_slice(), + "EventStream passthrough must be byte-identical through the tee" + ); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!( + stats["tokens"]["input"], 21, + "from base64-wrapped message_start" + ); + assert_eq!( + stats["tokens"]["output"], 9, + "monotone max from message_delta" + ); + assert_eq!(stats["tokens"]["cache_read"], 7); + assert_eq!(stats["requests"]["cached"], 1); +} + +/// Converse-stream in SSE-translation mode: usage arrives ONLY in the +/// camelCase `metadata` frame, which carries no Anthropic `type` and +/// is dropped by the Anthropic state machine — the fallback scan must +/// pick it up. +#[tokio::test] +async fn bedrock_converse_stream_sse_records_camel_metadata_usage() { + let mut body_bytes: Vec = Vec::new(); + for (et, payload) in [ + ("messageStart", json!({"role": "assistant"})), + ( + "contentBlockDelta", + json!({"contentBlockIndex": 0, "delta": {"text": "ok"}}), + ), + ("messageStop", json!({"stopReason": "end_turn"})), + ( + "metadata", + json!({"usage": {"inputTokens": 15, "outputTokens": 6, + "cacheWriteInputTokens": 11}, + "metrics": {"latencyMs": 250}}), + ), + ] { + body_bytes.extend_from_slice(&event_frame(et, &payload)); + } + + let upstream = MockServer::start().await; + let model_id = "anthropic.claude-3-5-haiku-20241022-v1:0"; + Mock::given(method("POST")) + .and(path(format!("/model/{model_id}/converse-stream"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/vnd.amazon.eventstream") + .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), + ) + .mount(&upstream) + .await; + let endpoint: Url = upstream.uri().parse().unwrap(); + let proxy = start_proxy_with_state( + &upstream.uri(), + |c| c.bedrock_endpoint = Some(endpoint), + |s| s.with_bedrock_credentials(test_credentials()), + ) + .await; + + let resp = reqwest::Client::new() + .post(format!("{}/model/{model_id}/converse-stream", proxy.url())) + .header("content-type", "application/json") + .header("accept", "text/event-stream") // SSE translation mode + .json(&json!({ + "messages": [{"role": "user", "content": [{"text": "hello"}]}] + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!( + stats["tokens"]["input"], 15, + "camelCase metadata usage captured" + ); + assert_eq!(stats["tokens"]["output"], 6); + assert_eq!(stats["tokens"]["cache_write"], 11); +} + +#[tokio::test] +async fn vertex_raw_predict_records_usage() { + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex( + r"^/v1beta1/projects/[^/]+/locations/[^/]+/publishers/anthropic/models/.+:rawPredict$", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", "type": "message", "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "ok"}], + "usage": {"input_tokens": 33, "output_tokens": 8, + "cache_read_input_tokens": 5} + }))) + .mount(&upstream) + .await; + let proxy = start_proxy_with_state( + &upstream.uri(), + |_| {}, + |s| install_static_token_source(s, "ya29.stats-test-bearer"), + ) + .await; + + let resp = reqwest::Client::new() + .post(format!( + "{}/v1beta1/projects/p1/locations/us-central1/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", + proxy.url() + )) + .header("content-type", "application/json") + .json(&json!({ + "anthropic_version": "vertex-2023-10-16", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["session_by_provider"]["vertex"]["requests"], 1); + assert_eq!(stats["tokens"]["input"], 33); + assert_eq!(stats["tokens"]["output"], 8); + assert_eq!(stats["tokens"]["cache_read"], 5); +} + +/// A proxy-side rejection (here: no AWS credentials configured) is an +/// outcome too — it must show in /stats as a failure, not vanish. An +/// operator-side credentials outage reading as "zero traffic" would +/// hide exactly the incident this dashboard exists to surface. +#[tokio::test] +async fn bedrock_proxy_side_rejection_records_failure() { + let upstream = MockServer::start().await; + let model_id = "anthropic.claude-3-5-haiku-20241022-v1:0"; + let endpoint: Url = upstream.uri().parse().unwrap(); + // No .with_bedrock_credentials(...) → handler must 5xx before + // any upstream attempt. + let proxy = start_proxy_with(&upstream.uri(), |c| c.bedrock_endpoint = Some(endpoint)).await; + + let resp = post_json( + format!("{}/model/{model_id}/invoke", proxy.url()), + &json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 500); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!(stats["requests"]["failed"], 1); + assert_eq!(stats["tokens"]["input"], 0); + assert_eq!(stats["session"]["savings_usd"], 0.0); + assert_eq!(stats["session_by_provider"]["bedrock"]["failed"], 1); +} + +/// Persistence through the real config surface: proxy A (started with +/// a stats path) records and flushes; proxy B on the same path serves +/// the lifetime numbers proxy A earned. +#[tokio::test] +async fn stats_path_survives_across_proxy_instances() { + let dir = std::env::temp_dir().join(format!("stats-restart-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let stats_path = dir.join("native_stats.json"); + + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", "type": "message", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "hi"}], + "usage": {"input_tokens": 60, "output_tokens": 6} + }))) + .mount(&upstream) + .await; + + // Proxy A: record one request, then flush via the ledger handle + // (the background flusher + shutdown flush live in main.rs, which + // this in-process harness deliberately doesn't run). + let ledger_slot: std::sync::Arc< + std::sync::Mutex>>, + > = Default::default(); + let slot = ledger_slot.clone(); + let sp = stats_path.clone(); + let proxy_a = start_proxy_with_state( + &upstream.uri(), + move |c| c.stats_path = Some(sp), + move |s| { + *slot.lock().unwrap() = Some(s.stats.clone()); + s + }, + ) + .await; + let resp = post_json( + format!("{}/v1/messages", proxy_a.url()), + &json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + wait_for_stats(&proxy_a.url(), |v| v["requests"]["total"] == 1).await; + let ledger = ledger_slot.lock().unwrap().take().unwrap(); + ledger.flush().await; + proxy_a.shutdown().await; + assert!(stats_path.exists(), "flush persisted the ledger"); + + // Proxy B on the same path: lifetime carries over, session fresh. + let sp = stats_path.clone(); + let proxy_b = start_proxy_with(&upstream.uri(), move |c| c.stats_path = Some(sp)).await; + let stats: Value = reqwest::Client::new() + .get(format!("{}/stats", proxy_b.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + stats["lifetime"]["requests"], 1, + "lifetime survived restart" + ); + assert_eq!(stats["lifetime"]["input_tokens"], 60); + assert_eq!(stats["session"]["requests"], 0, "session resets per boot"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A 200 OK SSE stream that fails IN-BAND (`{"type":"error"}` mid- +/// stream — e.g. Anthropic overloading during generation) is not a +/// billable success. The state machine lands in `Errored`; the +/// ledger must count the request as failed and accrue nothing. +#[tokio::test] +async fn in_stream_error_event_records_failure() { + let sse_body = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":", + "{\"input_tokens\":50,\"output_tokens\":1}}}\n\n", + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",", + "\"message\":\"Overloaded\"}}\n\n", + ); + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_body.as_bytes().to_vec(), "text/event-stream"), + ) + .mount(&upstream) + .await; + let proxy = start_proxy_with(&upstream.uri(), |_| {}).await; + + let resp = post_json( + format!("{}/v1/messages", proxy.url()), + &json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 32, + "stream": true, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200, "the HTTP status was a success"); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + assert_eq!( + stats["requests"]["failed"], 1, + "in-band stream error must count as a failed request" + ); + assert_eq!( + stats["tokens"]["input"], 0, + "failed requests accrue nothing" + ); + assert_eq!(stats["session"]["input_cost_usd"], 0.0); +} + +/// A hostile (or buggy) upstream reporting absurd token counts must +/// not poison the lifetime aggregates: the ledger clamps every +/// per-event token field to its 50M ceiling before pricing. +#[tokio::test] +async fn hostile_usage_counts_are_clamped_before_recording() { + let upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "hi"}], + "usage": { + "input_tokens": u64::MAX, + "output_tokens": 9_000_000_000_000u64, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0 + } + }))) + .mount(&upstream) + .await; + let proxy = start_proxy_with(&upstream.uri(), |_| {}).await; + + let resp = post_json( + format!("{}/v1/messages", proxy.url()), + &json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 32, + "messages": [{"role": "user", "content": "hello"}] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let _ = resp.bytes().await.unwrap(); + + let stats = wait_for_stats(&proxy.url(), |v| v["requests"]["total"] == 1).await; + const CEILING: u64 = 50_000_000; + assert_eq!(stats["tokens"]["input"], CEILING, "input clamped"); + assert_eq!(stats["tokens"]["output"], CEILING, "output clamped"); + let cost = stats["session"]["input_cost_usd"].as_f64().unwrap(); + assert!( + cost.is_finite() && cost < 1_000.0, + "clamped pricing stays sane: {cost}" + ); +} diff --git a/deploy/beacon/worker.js b/deploy/beacon/worker.js index 43cfb29f3a..208f70e761 100644 --- a/deploy/beacon/worker.js +++ b/deploy/beacon/worker.js @@ -6,7 +6,8 @@ * client sends AND exactly what happens to it on arrival. "Trust us" is not a * privacy policy. * - * Deployed at otlp.headroomlabs.ai. Three jobs: + * Deployed at headroom-beacon.headroom-beacon.workers.dev (otlp.headroomlabs.ai + * is planned but not yet routed; see wrangler.toml). Three jobs: * * 1. Allowlist. Drop every field not on ALLOWED_KEYS before anything is * written. This is the only privacy control that works retroactively — @@ -25,7 +26,9 @@ * * What this deliberately does NOT do: log, store, or forward the source IP. * Cloudflare offers it as cf-connecting-ip; it is the one field that would - * deanonymise install_id, so it is never read. + * deanonymise install_id, so it is never read. Cloudflare's own per-request + * invocation logs would record it regardless of this code, which is why + * wrangler.toml turns them off (observability.logs.invocation_logs = false). */ // Mostly mirrors the payload built by _Session.payload(); an extension may diff --git a/deploy/beacon/wrangler.toml b/deploy/beacon/wrangler.toml index 826dba6bb6..1824fb34d2 100644 --- a/deploy/beacon/wrangler.toml +++ b/deploy/beacon/wrangler.toml @@ -53,6 +53,14 @@ subrequests = 100000 [observability] enabled = true +# Invocation logs record every request's headers and Cloudflare metadata, +# which includes the sender's IP address (cf-connecting-ip, x-real-ip) and +# their city, postal code and coordinates. worker.js never reads these, but +# with invocation logs on, Cloudflare writes them to Workers Logs for every +# beacon POST anyway. Keep them off; errors and console.log still appear. +[observability.logs] +invocation_logs = false + # Rate limiting is configured in the Cloudflare dashboard, not here — this # endpoint is unauthenticated by design (anonymity is the product), so it is # the only thing between the Worker and a bored stranger: diff --git a/docker-compose.yml b/docker-compose.yml index 5036ed7b2a..394e99d6f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,9 @@ # 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 --rm -p 127.0.0.1:8787:8787 ghcr.io/headroomlabs-ai/headroom --host 0.0.0.0 --port 8787`); the two +# on its own (`docker run --rm -p 127.0.0.1:8787:8787 -e HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 +# ghcr.io/headroomlabs-ai/headroom --host 0.0.0.0 --port 8787`; the env var acknowledges that the +# 127.0.0.1 publication, not a token, is what keeps the port private); the two # database services below are only required for the memory/relevance features. # # Ports published on the host — all bound to 127.0.0.1 (this machine only): diff --git a/docker/docker-compose.native.yml b/docker/docker-compose.native.yml index 0995c3d799..aeac66f187 100644 --- a/docker/docker-compose.native.yml +++ b/docker/docker-compose.native.yml @@ -30,6 +30,11 @@ services: HOME: /tmp/headroom-home HEADROOM_HOST: 0.0.0.0 HEADROOM_PROXY_TOKEN: ${HEADROOM_PROXY_TOKEN:-} + # The container binds 0.0.0.0 so the loopback publication below can reach + # it; the proxy refuses that bind without a token unless the operator + # acknowledges it. This file always publishes on 127.0.0.1, so it does. + # Irrelevant (and harmless) once HEADROOM_PROXY_TOKEN is set. + HEADROOM_ALLOW_UNAUTHENTICATED_BIND: "1" # Canonical Headroom filesystem contract (issue #175). See `cli` service # above for rationale. HEADROOM_WORKSPACE_DIR: /tmp/headroom-home/.headroom diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index 38a9cb587b..c48b48b26e 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -1,4 +1,5 @@ import { RootProvider } from 'fumadocs-ui/provider/next'; +import { Banner } from 'fumadocs-ui/components/banner'; import './global.css'; import { Inter } from 'next/font/google'; import type { Metadata } from 'next'; @@ -48,7 +49,18 @@ export default function Layout({ children }: LayoutProps<'/'>) { return ( - {children} + + {/* Dismissible; Fumadocs remembers the dismissal per id in localStorage. */} + + + Headroom for Teams: 3 months free with a 12-month plan → + + + {children} + ); diff --git a/docs/assets/pr-screenshots/pr-1369-co2-card.png b/docs/assets/pr-screenshots/pr-1369-co2-card.png new file mode 100644 index 0000000000..92a7ee4de4 Binary files /dev/null and b/docs/assets/pr-screenshots/pr-1369-co2-card.png differ diff --git a/docs/bun.lock b/docs/bun.lock index b6ce4f02f1..39c7f75ba6 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -5,113 +5,113 @@ "": { "name": "headroom-docs", "dependencies": { - "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-slot": "1.3.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "dotted-map": "^3.1.0", - "fumadocs-core": "16.11.5", - "fumadocs-mdx": "15.2.0", - "fumadocs-twoslash": "^3.3.0", - "fumadocs-typescript": "^5.3.0", - "fumadocs-ui": "16.11.5", - "lucide-react": "^1.7.0", - "next": "16.3.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "recharts": "^3.9.2", - "tailwind-merge": "^3.5.0", + "fumadocs-core": "16.15.15", + "fumadocs-mdx": "15.4.5", + "fumadocs-twoslash": "^4.0.1", + "fumadocs-typescript": "^5.4.1", + "fumadocs-ui": "16.15.15", + "lucide-react": "^1.48.0", + "next": "^16.3.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "recharts": "^3.10.1", + "tailwind-merge": "^3.7.0", }, "devDependencies": { - "@ai-sdk/openai": "^3.0.51", - "@anthropic-ai/sdk": "^0.111.0", - "@tailwindcss/postcss": "^4.3.2", + "@ai-sdk/openai": "^4.0.79", + "@anthropic-ai/sdk": "^0.129.0", + "@tailwindcss/postcss": "^4.3.3", "@types/mdx": "^2.0.14", - "@types/node": "^26.1.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "ai": "7.0.59", - "openai": "^6.47.0", - "postcss": "^8.5.26", + "@types/node": "^26.6.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "ai": "7.0.119", + "openai": "^7.23.0", + "postcss": "^8.5.28", "tailwindcss": "^4.2.2", - "typescript": "^5.9.3", + "typescript": "^7.0.2", }, }, }, "overrides": { - "postcss": "^8.5.26", + "postcss": "^8.5.28", }, "packages": { - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.47", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.26", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BuWWlA8atWEnEJzN4eamCpGZWkoK9V3TCyNaMwgRIazhqkgWkA1Wp36si3j2JeVYzlvlEAOWrJ92dr92j/Vkqg=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.97", "", { "dependencies": { "@ai-sdk/provider": "4.0.19", "@ai-sdk/provider-utils": "5.0.50", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YFS9McgEDHXPwXOWlu1JCylkFyDsgPNLwH9jXgMxdiaur3k6SaIUPKCRSciQQ5LbEZOSb4JAaPYYO7B5Cvq7ow=="], - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, ""], + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.85", "", { "dependencies": { "@ai-sdk/provider": "4.0.23", "@ai-sdk/provider-utils": "5.0.55" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bMdfU4LJilFiD6DR6PipbhaSwfy14Uep4cSTRgElsv9Upmv+fNddSeqkBiEfobMlaPNT6zGfMdagonFlqZEtmQ=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, ""], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.23", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-TdEzgE1ndBGhDDwZcg/WZG3IeUj8Yz7/DkUG4ctMyK5HdNXTEKpjkCdwX8ayvkgluTv7yjnqCdOkB8/q1nfaNQ=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, ""], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.55", "", { "dependencies": { "@ai-sdk/provider": "4.0.23", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VeY9IqwJGcHSmezQTZknaYUT3sRcaXpuIt5VWyGOlJtoc5wkpDPstXZtfa41bGUsbQwCijvN6ASbggUCeTcMkw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, ""], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.111.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.129.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw=="], "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, ""], - "@base-ui/react": ["@base-ui/react@1.7.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.2", "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug=="], + "@base-ui/react": ["@base-ui/react@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "0.4.0", "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q=="], - "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="], + "@base-ui/utils": ["@base-ui/utils@0.4.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], @@ -123,61 +123,63 @@ "@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="], - "@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.1", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-BnPe52UxSaG8yKlHMKBxXw8h6GpK5qO55ci6+Qd5JnquTvIw6SpfbC1P+qAi82PuPWv1KZAWY8bxRk4+x9ctXw=="], + "@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.2", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-VppeXgIRXjZMgpf2qijj1hcfxyeYl1s0ok8k6N3jyjiX64M7sDwnt2zGPd6dbfDybgbBvDw6C6J1e42gB5NzsA=="], + + "@fumari/image-size": ["@fumari/image-size@0.1.1", "", {}, "sha512-iaiV2wyzJ4mW358NX4CX3yGit+WjKYzpaQ+rldK9BjL/5jFpeV6ruWnZVq7MEyMn3Gd0DLi8g/79NZAAQaGscw=="], "@img/colour": ["@img/colour@1.1.0", "", {}, ""], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, ""], @@ -185,31 +187,29 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, ""], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, ""], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, ""], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, ""], - "@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], - - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw=="], + "@next/env": ["@next/env@16.4.0", "", {}, "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA=="], - "@orama/orama": ["@orama/orama@3.1.18", "", {}, ""], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ=="], "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], @@ -223,13 +223,13 @@ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], @@ -255,7 +255,7 @@ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.18", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="], @@ -293,8 +293,6 @@ "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], - "@shikijs/twoslash": ["@shikijs/twoslash@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/types": "4.4.3", "twoslash": "^0.3.9" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-m7HNzunEIHRk1jCya3ngGsO3+8pYxrPIIxtdJewg/W8ceW/+m/mSsm4jM3L9DvYYNa8Rvbu7Dabt3BOpCclz8Q=="], - "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, ""], @@ -305,7 +303,7 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, ""], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, ""], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -337,8 +335,6 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], - "@ts-morph/common": ["@ts-morph/common@0.29.0", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg=="], - "@turf/boolean-point-in-polygon": ["@turf/boolean-point-in-polygon@7.3.4", "", { "dependencies": { "@turf/helpers": "7.3.4", "@turf/invariant": "7.3.4", "@types/geojson": "^7946.0.10", "point-in-polygon-hao": "^1.1.0", "tslib": "^2.8.1" } }, ""], "@turf/helpers": ["@turf/helpers@7.3.4", "", { "dependencies": { "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, ""], @@ -371,7 +367,7 @@ "@types/geojson": ["@types/geojson@7946.0.16", "", {}, ""], - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], @@ -379,17 +375,55 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, ""], - "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], - "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, ""], + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, ""], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, ""], - "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, ""], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, ""], @@ -397,35 +431,37 @@ "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], - "@yuku-analyzer/binding-darwin-arm64": ["@yuku-analyzer/binding-darwin-arm64@0.6.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9rpIP7IeybjyvWUf6WnU24h1qo+JdxIHr1o3yb06HoE8tM3S/Jh5RrUw9aw5P9BKSIvSPbLyVlItX7PcD3o5bQ=="], + "@yuku-analyzer/binding-android-arm64": ["@yuku-analyzer/binding-android-arm64@0.10.2", "", { "os": "android", "cpu": "arm64" }, "sha512-xt4Vy3RTBw1NfszBlf4A0Rr6ZjA/th2Pj9XlWFLQHW9A1qOCtxNCoIWSzn/INtnZLMB4jO2gWdEtSaHUtK73wA=="], - "@yuku-analyzer/binding-darwin-x64": ["@yuku-analyzer/binding-darwin-x64@0.6.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ELLhNT4FGnqY8yh0W3cSs9rGMSeUyhib1aYD84RupjlfsrDTrQRoDhWu01Dv6xCfYgASYaj1Abntk91A7njNag=="], + "@yuku-analyzer/binding-darwin-arm64": ["@yuku-analyzer/binding-darwin-arm64@0.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ftokSqvYXZxfmA/bfIbp8c1BJ8QxNax371kUOQNFyvR5hmtKqQyTqSKtMk+WaixHM3w4tKj/JdyY+ud1bGamXA=="], - "@yuku-analyzer/binding-freebsd-x64": ["@yuku-analyzer/binding-freebsd-x64@0.6.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-s76XocUMlK9liTyipALFb2K64ku35u/wg238A0NW8U5CUDsuIe/8tu5TzdLjJAGxnd0IV+gBneDt9cJJzLeFRQ=="], + "@yuku-analyzer/binding-darwin-x64": ["@yuku-analyzer/binding-darwin-x64@0.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yUrmmhmF5R4Xmbfo8gUxJKr3YbvbLcE2JvLhDLzFJkMN29/fGm6K3dZX6YpNecfgBDnQ+VywGp8K9xjRaBFZ3Q=="], - "@yuku-analyzer/binding-linux-arm-gnu": ["@yuku-analyzer/binding-linux-arm-gnu@0.6.12", "", { "os": "linux", "cpu": "arm" }, "sha512-hm8Tq0umop3RGu6dOMF61q69tYn1bDp1CeYD5ZjuGFQJclp0moVtjzY4z0bzusicKeZ9+k5LRroR0p5HWC2hDw=="], + "@yuku-analyzer/binding-freebsd-x64": ["@yuku-analyzer/binding-freebsd-x64@0.10.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-cy9+KvVoK5Xad3Vs5pLwAeZf3JC/1y0XJElgv1q6g7W7g/n52FRjedpYmQiJeMZdnhJiAshq7Vz/Xj3jC5Ns2Q=="], - "@yuku-analyzer/binding-linux-arm-musl": ["@yuku-analyzer/binding-linux-arm-musl@0.6.12", "", { "os": "linux", "cpu": "arm" }, "sha512-CxtPKLddogHAB3ZHVWaUl+U8jx0pdriTSbQ1K/orlDqU0GDhg8LuIRyUscP7r2/62fGGMzkc119fE71I4Nl1Fg=="], + "@yuku-analyzer/binding-linux-arm-gnu": ["@yuku-analyzer/binding-linux-arm-gnu@0.10.2", "", { "os": "linux", "cpu": "arm" }, "sha512-AgVZ6VCV2XP4yHCwyv78KLV76gcu2neiEF2+RBTPQN7sqYY+bLvMxphGPmfG+9qk5Uf1PJAhZYIUnMGVbbRCZw=="], - "@yuku-analyzer/binding-linux-arm64-gnu": ["@yuku-analyzer/binding-linux-arm64-gnu@0.6.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EOyLcpAmF5qAVDKmKvV7xt8oBGeWQ92CqFI4s7h7TRlrF6TfGRrh8PwawGn92gFploNLAYj/1Z9Q1gVvwGgG9g=="], + "@yuku-analyzer/binding-linux-arm-musl": ["@yuku-analyzer/binding-linux-arm-musl@0.10.2", "", { "os": "linux", "cpu": "arm" }, "sha512-5qAj2GjoSN6+HuH75+g5r40CFhWmMvH0K8VKWmPfQyOgA/LonoDMr5H5O5feJwzdnfdCytPDAh1RAf7/SSg7dg=="], - "@yuku-analyzer/binding-linux-arm64-musl": ["@yuku-analyzer/binding-linux-arm64-musl@0.6.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-T3eCYy6bMnVRMQEYAbDcpj08/XM93dBTtnn/DDocJN21RARe+KCzWKeL26J3yd3bOW3WVjVLq09BfdpAGB0buQ=="], + "@yuku-analyzer/binding-linux-arm64-gnu": ["@yuku-analyzer/binding-linux-arm64-gnu@0.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-eJvQ4Ja5oPf/HOjmBkCLYyhOHUkL7U+iMAN8sX44x8exBBpPRyqnhEgTsuU73HzxPLrGPdRhybFnAE63Omib/Q=="], - "@yuku-analyzer/binding-linux-x64-gnu": ["@yuku-analyzer/binding-linux-x64-gnu@0.6.12", "", { "os": "linux", "cpu": "x64" }, "sha512-1Y+noIuvnDugIVsoIr5NduZqX7KuFTzICSkvG8RW3OKK9URVeTOicKK217i44ABZSSZJ7A0E7vzifapx0c9VDw=="], + "@yuku-analyzer/binding-linux-arm64-musl": ["@yuku-analyzer/binding-linux-arm64-musl@0.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-TppF2j194mOQ/kBXvNr9u5TYO+eR9r8ieg4lTIEyJIVYHAR9GWeB0oFHLcJiLXVa3qUJGzE4bmzSntCW5ylA7A=="], - "@yuku-analyzer/binding-linux-x64-musl": ["@yuku-analyzer/binding-linux-x64-musl@0.6.12", "", { "os": "linux", "cpu": "x64" }, "sha512-woN/GuG95Fd6bp+ZQfmiFrZnoA2hdu3vfVSc89A8LElnYpzFaJM81sOZp8f3tVOVUJxbt7KAUiCLwSy34MJKqA=="], + "@yuku-analyzer/binding-linux-x64-gnu": ["@yuku-analyzer/binding-linux-x64-gnu@0.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-03xu6ocJuwhm1a/JKmrHiaJUO1Oj86rABsm4FAl8AvfewfW/9idIUmPiFxZfb6RfiIONegebsTGJhFx0NbBNpA=="], - "@yuku-analyzer/binding-win32-arm64": ["@yuku-analyzer/binding-win32-arm64@0.6.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-8OVFnKbK+lgsL6MqILPLpzlsa00K4KiKsdbHH94hpGcrqaz1jv+k0Y7ujSaoYTWw5Bb7Lr9GJ3L1n1hT2sXoYA=="], + "@yuku-analyzer/binding-linux-x64-musl": ["@yuku-analyzer/binding-linux-x64-musl@0.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-f7W2sAWWQN8x8YB0RRxIche7mLImH7lDgzUy9+B69cM2I6VFJ4a5+0pgAuMLROPOqimaxFPdXqLdDE/vpgGltg=="], - "@yuku-analyzer/binding-win32-x64": ["@yuku-analyzer/binding-win32-x64@0.6.12", "", { "os": "win32", "cpu": "x64" }, "sha512-3w8w1Xc5njwgbGTcn3JfDxWuQnFvtSll1D8gBlk4U8CI5v7ibKOMIdABucCXH8WtsRREG0ME5Vn0i422eX3zLQ=="], + "@yuku-analyzer/binding-win32-arm64": ["@yuku-analyzer/binding-win32-arm64@0.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-f534qNc5YaJI29e1oryxEnxPkp7WCYJ1j7ChSec8pQNlYqTm8Tao7hij7NxRF+9BuxzeZCZ+WsbaAfLlklR0Eg=="], - "@yuku-toolchain/types": ["@yuku-toolchain/types@0.6.11", "", {}, "sha512-i1JYFNJaKNCgyJ/nVoR8GK7wvlXF+ShYzFHBauWcvg8IoiXInK7pVziHcgNz/MWLPNr/Mb/CtmXccrJMkKqSHQ=="], + "@yuku-analyzer/binding-win32-x64": ["@yuku-analyzer/binding-win32-x64@0.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sKHkmLK8BsindMfyIThoN0bWC5uqSJEVNaLRD/ifA99mtb0UDin1ZCg4WYSVQAmM4k2iOTl4UZx/p3zcBiBgYw=="], + + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.10.2", "", {}, "sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q=="], "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, ""], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, ""], - "ai": ["ai@7.0.59", "", { "dependencies": { "@ai-sdk/gateway": "4.0.47", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-p10cqg8KvLIZi7Gk+XsQjoYoLENoDdEYvehP0rSo6Wg15nbCmP+nwIy1rOIX+WGIDWGdI+jmhjVx5fXShBVC3w=="], + "ai": ["ai@7.0.119", "", { "dependencies": { "@ai-sdk/gateway": "4.0.97", "@ai-sdk/provider": "4.0.19", "@ai-sdk/provider-utils": "5.0.50" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KlHbkKMmdb2g0DXdkhBXzSvLSkdoAg0MdShhTTOyuoHKJetEUlgjn2juZ05lHY97EqCwBuSK8dgiEeB0ty5xZw=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, ""], @@ -433,12 +469,8 @@ "bail": ["bail@2.0.2", "", {}, ""], - "balanced-match": ["balanced-match@4.0.4", "", {}, ""], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": "dist/cli.cjs" }, ""], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "caniuse-lite": ["caniuse-lite@1.0.30001786", "", {}, ""], "ccount": ["ccount@2.0.1", "", {}, ""], @@ -459,9 +491,7 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cnfast": ["cnfast@0.0.8", "", { "bin": { "cnfast": "bin/cli.js" } }, "sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q=="], - - "code-block-writer": ["code-block-writer@13.0.3", "", {}, ""], + "cn": ["cn@0.2.6", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, ""], @@ -519,7 +549,7 @@ "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, ""], - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": "bin/esbuild" }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, ""], @@ -541,7 +571,7 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, ""], - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, ""], + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "extend": ["extend@3.0.2", "", {}, ""], @@ -549,17 +579,17 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, ""], - "framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], + "framer-motion": ["framer-motion@13.5.1", "", { "dependencies": { "motion-dom": "13.5.1", "motion-utils": "13.5.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-HeWOaeHD3kyTSIEMT6+nw7fBn4saIfxxabZyAITbycAl4xr1kzGJKIvGt/OWghSlvIA9qTnZ1zTGelHAe6S8DA=="], - "fumadocs-core": ["fumadocs-core@16.11.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.3.1", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.0" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x || 8.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-YrHjS09+QYYKOSTGyiZbxF/VDs7ciMcjurYBGfmYqtzdj14k7Ho0HX9c6VuvG54YsYHQs5mGWemT1TXm7vDBaA=="], + "fumadocs-core": ["fumadocs-core@16.15.15", "", { "dependencies": { "@fumari/image-size": "^0.1.1", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "npm-to-yarn": "3.2.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.4.3", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.1", "zbsearch": "^4.0.0" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@modelcontextprotocol/server": "2.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x || 8.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@modelcontextprotocol/server", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-kEQLKRlpRl16MfMrlAYiEpk3FPadn4725R3QHYVnF3tJhflRXvznWaJ5pP33F09d6qnOss5onEIgMpAO6rOrpA=="], - "fumadocs-mdx": ["fumadocs-mdx@15.2.0", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.1", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "magic-string": "^0.30.21", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.5", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.0", "yuku-analyzer": "^0.6.3", "zod": "^4.4.3" }, "peerDependencies": { "@fumadocs/satteri": "0.x.x", "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", "satteri": "^0.9.4", "vite": "7.x.x || 8.x.x" }, "optionalPeers": ["@fumadocs/satteri", "@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "rolldown", "satteri", "vite"], "bin": { "fumadocs-mdx": "./bin.js" } }, "sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA=="], + "fumadocs-mdx": ["fumadocs-mdx@15.4.5", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.2", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "magic-string": "^1.4.1", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.7", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.1", "yuku-analyzer": "^0.10.2", "zod": "^4.6.5" }, "peerDependencies": { "@fumadocs/satteri": "0.x.x", "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^16.15.3", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", "satteri": "^0.10.5", "vite": "7.x.x || 8.x.x" }, "optionalPeers": ["@fumadocs/satteri", "@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "rolldown", "satteri", "vite"], "bin": { "fumadocs-mdx": "./bin.js" } }, "sha512-r8gzaqleN+YXgPIbkYSZ05zr89dsr67/64Ar/00Uu7dRWTIHzbgmN1nC0lB8RFNeWnrL41bOTJilhLM4PAsrbw=="], - "fumadocs-twoslash": ["fumadocs-twoslash@3.3.0", "", { "dependencies": { "@base-ui/react": "^1.6.0", "@shikijs/twoslash": "^4.3.1", "cnfast": "^0.0.8", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-hast": "^13.2.1", "twoslash": "^0.3.9" }, "peerDependencies": { "@types/react": "*", "fumadocs-core": "^16.7.16", "fumadocs-ui": "^16.7.16", "react": "^19.2.0", "react-dom": "^19.2.0", "shiki": "4.x.x" }, "optionalPeers": ["@types/react"] }, "sha512-IR+oYbjsR59h/R7AvdeEdHEY48ygCHII3tUZGC3QnUVug5+qN1wlRNr30gAfOvrau/mdiRKwONuJCOKOWqEFbg=="], + "fumadocs-twoslash": ["fumadocs-twoslash@4.0.1", "", { "dependencies": { "@base-ui/react": "^1.8.0", "@types/hast": "^3.0.5", "cn": "^0.2.6", "mdast-util-from-markdown": "^2.0.3", "mdast-util-to-hast": "^13.2.1", "typescript": "~7.0.2" }, "peerDependencies": { "@types/react": "*", "fumadocs-core": "^16.15.7", "fumadocs-ui": "^16.7.16", "react": "^19.2.0", "react-dom": "^19.2.0", "shiki": "^4.4.3" }, "optionalPeers": ["@types/react"] }, "sha512-9+mDaQJZGSI657IsX+kfvtGHYulvHdI0uVgD2hd32i5RMERH2TOgHaq7zz/TKTI7L4WDhx8MZ0BRjyOiso1KMQ=="], - "fumadocs-typescript": ["fumadocs-typescript@5.3.0", "", { "dependencies": { "estree-util-value-to-estree": "^3.5.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "remark": "^15.0.1", "remark-rehype": "^11.1.2", "shiki": "^4.3.1", "ts-morph": "^28.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/estree": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "fumadocs-ui": "^16.7.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/estree", "@types/hast", "@types/mdast", "@types/react", "fumadocs-ui"] }, "sha512-UwYug2z01/hsjgmyoPL/MCz66pyZarpxOV9hIgnyNIhopwwcReCykEnRjchY342gIhZqUjTlY/TCJRorkCHhBA=="], + "fumadocs-typescript": ["fumadocs-typescript@5.4.1", "", { "dependencies": { "estree-util-value-to-estree": "^3.5.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "remark": "^15.0.1", "remark-rehype": "^11.1.2", "shiki": "^4.4.3", "typescript": "~7.0.2", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/estree": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "fumadocs-ui": "^16.7.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/estree", "@types/hast", "@types/mdast", "@types/react", "fumadocs-ui"] }, "sha512-qW61uHWNnK7XWFpINeWedX9UMQotPOLDC5oHybwA5l39CAR0xC/G6hlyGgTF2WH4AQjsFDwDAwgVuuU4dbmNJQ=="], - "fumadocs-ui": ["fumadocs-ui@16.11.5", "", { "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.1.1", "@radix-ui/react-accordion": "^1.2.16", "@radix-ui/react-collapsible": "^1.1.16", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-direction": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.18", "@radix-ui/react-popover": "^1.1.19", "@radix-ui/react-presence": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.14", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tabs": "^1.1.17", "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", "lucide-react": "^1.24.0", "motion": "^12.42.2", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.3.1", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.11.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "takumi-js": "*" }, "optionalPeers": ["@types/mdx", "@types/react", "next", "takumi-js"] }, "sha512-Eda7x2Hk7E1iIjZ4uES0xxGr25Z72efRM5kP8sbgLSLhWg8TDCyWddvKAkzXIq8bupPOuJkdZa/YVvXbCktIEA=="], + "fumadocs-ui": ["fumadocs-ui@16.15.15", "", { "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.1.2", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-collapsible": "^1.1.20", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-direction": "^1.1.4", "@radix-ui/react-navigation-menu": "^1.2.22", "@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-presence": "^1.1.10", "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-tabs": "^1.1.21", "class-variance-authority": "^0.7.1", "cn": "^0.3.0", "lucide-react": "^1.47.0", "motion": "^13.4.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.4.3", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.15.15", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "takumi-js": "*" }, "optionalPeers": ["@types/mdx", "@types/react", "next", "takumi-js"] }, "sha512-wE9gTz+JYoz9wwxaNCfj7jqN7c7oAY7lCyuFootpY8jmGiyZHfy6db2cxlRwjm+qEaLdsV/KOsAjbuuT/n8elg=="], "get-nonce": ["get-nonce@1.0.1", "", {}, ""], @@ -635,9 +665,9 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, ""], - "lucide-react": ["lucide-react@1.20.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ=="], + "lucide-react": ["lucide-react@1.52.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, ""], + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, ""], @@ -747,49 +777,47 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, ""], - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, ""], + "motion": ["motion@13.5.1", "", { "dependencies": { "framer-motion": "13.5.1", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-eV9SUem6KnzEa9+eT+dVBeDlhy5TTdn09YWR1WhUsKoZCJPw/n4TcYhjbxq2Gy5PTwDr//4H3r5vxkcZnNnO1w=="], - "motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="], + "motion-dom": ["motion-dom@13.5.1", "", { "dependencies": { "motion-utils": "13.5.0" } }, "sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg=="], - "motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], - - "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "motion-utils": ["motion-utils@13.5.0", "", {}, "sha512-Hqe/sqjIKliPZg8sVjWAkdySDPJNIeKH+euVkDOG8ri3DUUU7ZjndmHsGqEPz2iHpiyVaV5I22iguCMHU2he+Q=="], "ms": ["ms@2.1.3", "", {}, ""], - "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - "next": ["next@16.3.0", "", { "dependencies": { "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-x64": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-musl": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.0", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A=="], + "next": ["next@16.4.0", "", { "dependencies": { "@next/env": "16.4.0", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.0", "@next/swc-darwin-x64": "16.4.0", "@next/swc-linux-arm64-gnu": "16.4.0", "@next/swc-linux-arm64-musl": "16.4.0", "@next/swc-linux-x64-gnu": "16.4.0", "@next/swc-linux-x64-musl": "16.4.0", "@next/swc-win32-arm64-msvc": "16.4.0", "@next/swc-win32-x64-msvc": "16.4.0", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, ""], + "npm-to-yarn": ["npm-to-yarn@3.2.0", "", {}, "sha512-K1HmQeZT2HrjpsR6KgqbN2FAXL2NrJJmNUSD9ck7HGTVu1JKXox8n9SB+tjbU8m8JGLF4OscrroPepew/L7/Xw=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="], + "openai": ["openai@7.30.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-tvvcPdhS56B/79o0mJS5h4JndAov5Sz78T7CFl4gJ+C9S3GoSqHNLO6lG/8kdK+/c13zWEI97Srf66ar0V242g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, ""], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, ""], - "path-browserify": ["path-browserify@1.0.1", "", {}, ""], - "picocolors": ["picocolors@1.1.1", "", {}, ""], - "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "point-in-polygon-hao": ["point-in-polygon-hao@1.2.4", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, ""], - "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], "proj4": ["proj4@2.20.8", "", { "dependencies": { "mgrs": "1.0.0", "wkt-parser": "^1.5.5" } }, ""], "property-information": ["property-information@7.1.0", "", {}, ""], - "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-is": ["react-is@19.2.5", "", {}, ""], @@ -843,19 +871,19 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, ""], - "scheduler": ["scheduler@0.27.0", "", {}, ""], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, ""], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], "source-map": ["source-map@0.7.6", "", {}, ""], - "source-map-js": ["source-map-js@1.2.1", "", {}, ""], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, ""], @@ -869,7 +897,7 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, ""], - "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], "tailwindcss": ["tailwindcss@4.2.2", "", {}, ""], @@ -877,7 +905,7 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, ""], - "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -887,19 +915,13 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, ""], - "ts-morph": ["ts-morph@28.0.0", "", { "dependencies": { "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g=="], - "tslib": ["tslib@2.8.1", "", {}, ""], - "twoslash": ["twoslash@0.3.9", "", { "dependencies": { "@typescript/vfs": "^1.6.4", "twoslash-protocol": "0.3.9" }, "peerDependencies": { "typescript": "^5.5.0 || ^6.0.0" } }, "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q=="], - - "twoslash-protocol": ["twoslash-protocol@0.3.9", "", {}, "sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, ""], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, ""], @@ -935,104 +957,98 @@ "wkt-parser": ["wkt-parser@1.5.5", "", {}, ""], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - - "yuku-analyzer": ["yuku-analyzer@0.6.12", "", { "dependencies": { "@yuku-toolchain/types": "0.6.11", "yuku-ast": "0.6.11" }, "optionalDependencies": { "@yuku-analyzer/binding-darwin-arm64": "0.6.12", "@yuku-analyzer/binding-darwin-x64": "0.6.12", "@yuku-analyzer/binding-freebsd-x64": "0.6.12", "@yuku-analyzer/binding-linux-arm-gnu": "0.6.12", "@yuku-analyzer/binding-linux-arm-musl": "0.6.12", "@yuku-analyzer/binding-linux-arm64-gnu": "0.6.12", "@yuku-analyzer/binding-linux-arm64-musl": "0.6.12", "@yuku-analyzer/binding-linux-x64-gnu": "0.6.12", "@yuku-analyzer/binding-linux-x64-musl": "0.6.12", "@yuku-analyzer/binding-win32-arm64": "0.6.12", "@yuku-analyzer/binding-win32-x64": "0.6.12" } }, "sha512-0zu/gwv6nKA3wm2GMjM1iczw9rbt77ijEyR5tXpPQ8AZcXIpXlll66BXOtMHgYudLn91bJx0ybhpARoJWm5/dw=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], - "yuku-ast": ["yuku-ast@0.6.11", "", { "dependencies": { "@yuku-toolchain/types": "0.6.8" } }, "sha512-ZfXkFYVsDewS45+kv3WiA/qNB73CRfxFDEQwfnRMUAR4AD5zRI7PRqxmI2U3Jz/oG41GneTVW6mxDOQal0lgeA=="], + "yuku-analyzer": ["yuku-analyzer@0.10.2", "", { "dependencies": { "@yuku-toolchain/types": "^0.10.2", "yuku-ast": "^0.10.2" }, "optionalDependencies": { "@yuku-analyzer/binding-android-arm64": "0.10.2", "@yuku-analyzer/binding-darwin-arm64": "0.10.2", "@yuku-analyzer/binding-darwin-x64": "0.10.2", "@yuku-analyzer/binding-freebsd-x64": "0.10.2", "@yuku-analyzer/binding-linux-arm-gnu": "0.10.2", "@yuku-analyzer/binding-linux-arm-musl": "0.10.2", "@yuku-analyzer/binding-linux-arm64-gnu": "0.10.2", "@yuku-analyzer/binding-linux-arm64-musl": "0.10.2", "@yuku-analyzer/binding-linux-x64-gnu": "0.10.2", "@yuku-analyzer/binding-linux-x64-musl": "0.10.2", "@yuku-analyzer/binding-win32-arm64": "0.10.2", "@yuku-analyzer/binding-win32-x64": "0.10.2" } }, "sha512-UBRcDcZtIn14g88loZUZ123d6MOkc2hMTawUle5MkuWboowM90KbWHN3Zz+HfuS06sw935ygecBpcJlXHB/pOA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "yuku-ast": ["yuku-ast@0.10.2", "", { "dependencies": { "@yuku-toolchain/types": "^0.10.2" } }, "sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ=="], - "zwitch": ["zwitch@2.0.4", "", {}, ""], - - "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], + "zbsearch": ["zbsearch@4.0.1", "", {}, "sha512-lmnEEnb93QQcNv7qARDr7jBVPxYdZbZ9XdZ0dbZ0qaOvGne4jJMW4fjwPfVaUKQXv3FNWvGbDwRDgyaSb1pc8w=="], - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.26", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0mqhqx+Dcv+msI84+zkbLXmnCHb/mpkYJ9DU3HBvB1px+UjFQss4lLZqRCSzJ0w3NxwQk4Ishk7cP0r8wEQD5w=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - "@radix-ui/react-accordion/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "zwitch": ["zwitch@2.0.4", "", {}, ""], - "@radix-ui/react-accordion/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.19", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ryVZSSfmpEsr4bM8rbt9cSBWal+KGYRrm57C+k/hiPQHMiMahaqg2bPF0MpxSUWY8mGpt7s/ajM6PAqNTMKqPQ=="], - "@radix-ui/react-collapsible/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.50", "", { "dependencies": { "@ai-sdk/provider": "4.0.19", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qg2LutM5RlUT/tuuFf+VkhFGNZ2sEJHL0oDIrFN/sMoVbDBANglqtGCJUOnBqGMoFBH8IfAXMrFZvbFWx4dMOg=="], - "@radix-ui/react-collection/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, ""], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, ""], - "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@mdx-js/mdx/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@radix-ui/react-focus-scope/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, ""], - "@radix-ui/react-navigation-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@reduxjs/toolkit/reselect": ["reselect@5.1.1", "", {}, ""], - "@radix-ui/react-navigation-menu/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, ""], - "@radix-ui/react-popover/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@tailwindcss/node/source-map-js": ["source-map-js@1.2.1", "", {}, ""], - "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], - "@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@radix-ui/react-roving-focus/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@radix-ui/react-roving-focus/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], - "@radix-ui/react-scroll-area/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@radix-ui/react-scroll-area/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, ""], - "@radix-ui/react-tabs/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], - "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, ""], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.19", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ryVZSSfmpEsr4bM8rbt9cSBWal+KGYRrm57C+k/hiPQHMiMahaqg2bPF0MpxSUWY8mGpt7s/ajM6PAqNTMKqPQ=="], - "@reduxjs/toolkit/reselect": ["reselect@5.1.1", "", {}, ""], + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.50", "", { "dependencies": { "@ai-sdk/provider": "4.0.19", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qg2LutM5RlUT/tuuFf+VkhFGNZ2sEJHL0oDIrFN/sMoVbDBANglqtGCJUOnBqGMoFBH8IfAXMrFZvbFWx4dMOg=="], - "@shikijs/core/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "fumadocs-ui/cn": ["cn@0.3.3", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-OEiyJkSpYm2kOuNOKQ+c2wmzZRG8orrb9fnObtHTbCGNx/E9KLnGcmcocItOzREKk5KzQN5I2e3BUCj563KEpw=="], - "@shikijs/primitive/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "hast-util-from-parse5/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@shikijs/types/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "hast-util-parse-selector/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "hast-util-raw/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + "hast-util-to-estree/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "hast-util-to-html/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "hast-util-to-jsx-runtime/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], + "hast-util-to-parse5/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "hast-util-whitespace/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, ""], + "hastscript/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "mdast-util-mdx-expression/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], + "mdast-util-mdx-jsx/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.26", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0mqhqx+Dcv+msI84+zkbLXmnCHb/mpkYJ9DU3HBvB1px+UjFQss4lLZqRCSzJ0w3NxwQk4Ishk7cP0r8wEQD5w=="], + "mdast-util-mdxjs-esm/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "fumadocs-ui/lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="], + "mdast-util-to-hast/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, ""], - "shiki/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "rehype-raw/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, ""], - - "yuku-ast/@yuku-toolchain/types": ["@yuku-toolchain/types@0.6.8", "", {}, "sha512-AbUd1775RVkOxJkh8hkldIWoU6kRMTCsZFSZq8Ny53q7GkbaVe5UCfleNZ3RWCoz/ZKE8qwfeB7Cj0xqhLWsKA=="], + "rehype-recma/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@ai-sdk/gateway/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "remark-rehype/@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, ""], - "@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, ""], - "ai/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, ""], } } diff --git a/docs/content/docs/agent-hooks.mdx b/docs/content/docs/agent-hooks.mdx new file mode 100644 index 0000000000..782cc22844 --- /dev/null +++ b/docs/content/docs/agent-hooks.mdx @@ -0,0 +1,28 @@ +--- +title: Agent Hooks +description: Auto-start a durable headroom deployment when Claude Code or Copilot CLI sessions begin, via the bundled agent-hooks plugin. +--- + +The bundled [`plugins/headroom-agent-hooks`](https://github.com/headroomlabs-ai/headroom/tree/main/plugins/headroom-agent-hooks) plugin exposes lightweight startup hooks for **Claude Code** and **GitHub Copilot CLI**. It ensures a matching durable `headroom init` deployment exists and starts it if needed — so agents never begin work against a dead proxy. + +## What the hooks do + +Each hook runs one hidden helper: + +```bash +headroom init hook ensure +``` + +It checks for a matching durable `headroom init` deployment and starts it if needed. The plugin registers it at two moments (15s timeout each): + +| Hook | Matcher | Effect | +|------|---------|--------| +| `SessionStart` | `startup\|resume` | deployment is ready before the first turn | +| `PreToolUse` | `Bash\|PowerShell` | shell tools always hit a live proxy | + +## Install + +- **Claude Code**: the plugin ships a `.claude-plugin/plugin.json` manifest — install it as a Claude Code plugin from the headroom repository. +- **GitHub Copilot CLI**: a matching `.github/plugin/plugin.json` manifest is included for Copilot's plugin mechanism. + +Both paths use the same `hooks/hooks.json` definitions, so behavior is identical. diff --git a/docs/content/docs/cache-optimization.mdx b/docs/content/docs/cache-optimization.mdx index 518be22ff4..0d5ffb6bc1 100644 --- a/docs/content/docs/cache-optimization.mdx +++ b/docs/content/docs/cache-optimization.mdx @@ -72,6 +72,17 @@ Keep the stable prefix first, keep volatile content out of it, and treat CacheAl CacheAligner surfaces prefix instability, provider caches reward byte-identical prefixes, and the caller owns the actual message layout. +Anthropic prefix trackers are retained for at least the resolved provider cache +lifetime plus a 60-second cleanup margin, or the configured session cleanup TTL, +whichever is longer. A configured cache lifetime also applies before the first +confirmed cache write and when a request omits its TTL. Retention is tracked per +conversation lineage: a one-hour session does not extend an unrelated +five-minute session. Expired trackers are discarded before lookup; a later +longer TTL cannot revive their frozen state. Cleanup and recreation retain the +live lineage index, keeping sibling conversations and tool profiles isolated. +Retaining a tracker is not a guarantee that the provider still has its cache. + + ## Cold-prefix recompaction (when the cache lapses) Byte-identical forwarding only pays off while the prompt cache is warm. When a diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index ab81033631..f21a97db24 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -401,6 +401,7 @@ headroom proxy --learn --min-evidence 3 | `HEADROOM_BETA_HEADER_STICKY` | Controls per-session `anthropic-beta` / `OpenAI-Beta` re-echo. `enabled` (default): the proxy unions beta tokens across turns within a session — if the client sends a token in turn N and omits it in turn N+1, the proxy re-injects it to preserve prefix-cache stability. `disabled`: the client's value is forwarded verbatim with no accumulation. Any other value raises at request time. See [Session Beta Header Tracking](/docs/configuration#session-beta-header-tracking). | `enabled` | | `HEADROOM_BETA_TRACKER_MAX_SESSIONS` | LRU capacity of the in-memory session beta tracker. Once full, the oldest session entry is evicted. | `1000` | | `HEADROOM_PROXY_BETA_HEADER_STICKY` | Rust proxy: same per-conversation beta-token union as `HEADROOM_BETA_HEADER_STICKY`, applied to `anthropic-beta` / `openai-beta` on the intercepted `/v1/messages`, `/v1/chat/completions`, and `/v1/responses` routes. Requires the compression interceptor (`HEADROOM_PROXY_COMPRESSION=1`) — with it off the Rust proxy is a strict byte-pipe and this flag has no effect (startup warns). Unlike the Python tracker (keyed on model + system prompt), sessions are keyed per conversation, shared with the cache-drift detector — parallel conversations never inherit each other's tokens. `enabled` default; `disabled` forwards the client value verbatim and keeps no state. Tracker capacity is fixed at 1000 sessions. | `enabled` | +| `HEADROOM_LIVE_ZONE_DISABLE_ARMS` | Rust proxy live-zone dispatcher: comma-separated content types whose compression arm is forced to a no-op, e.g. `source_code,plain_text`. Names are accepted in either the log-tag spelling (`json_array`, `source_code`, `search`, `build`, `diff`, `html`, `text`) or the natural spelling (`search_results`, `build_output`, `git_diff`, `plain_text`). Read once, on the first dispatch of the process, so a running proxy's behaviour cannot change mid-flight; unknown names are logged and ignored rather than failing the request. Shipped as the rollback switch for the `SourceCode` → CodeAwareCompressor and `PlainText` → Kompress arms, but applies to every arm. | -- | | `HEADROOM_MODEL_ROUTER_ENABLED` | Enable cost-aware model routing. `1`/`true`/`yes`/`on`/`enabled` turns it on and requires `HEADROOM_MODEL_ROUTES`. See [Cost-aware model routing](/docs/configuration#cost-aware-model-routing). | `off` | | `HEADROOM_MODEL_ROUTES` | JSON array of ordered routing rules for cost-aware model routing (schema below). | -- | | `HEADROOM_THINKING_COMPACT` | Compact plain-text reasoning that models re-send every turn (Kimi/GLM/DeepSeek `reasoning_content` / inline ``): Kompress it on warm turns, drop it on cold turns. No-op for Claude/Codex/OpenAI (encrypted reasoning). See [Cold-prefix hook](#cold-prefix-hook--reasoning-compaction). | `off` | @@ -485,6 +486,7 @@ Enable it with `HEADROOM_MODEL_ROUTER_ENABLED=1` and declare ordered rules in `H | `to_model` | string (required) | Model to route to when the rule matches. | | `max_input_tokens` | integer | Match only when the estimated input size is at or below this. | | `min_input_tokens` | integer | Match only when the estimated input size is at or above this. | +| `max_output_tokens` | integer | Match only when the request's own `max_tokens` is at or below this. A request without `max_tokens` does not match. | | `require_no_tools` | boolean | Match only when the request declares no tools (a proxy for low-risk work). | | `from_models` | list of strings | Restrict the rule to these source models. Omit for any source model. | | `name` | string | Label surfaced in the decision log. | @@ -499,6 +501,7 @@ export HEADROOM_MODEL_ROUTES='[ Notes: +- Combine `max_input_tokens` with `max_output_tokens` to downshift only turns that are cheap on both sides: a tiny prompt that asks for a long answer (a large `max_tokens`) stays on the original model. - Input size is a fast, tokenizer-free estimate over the messages, tools, and top-level `system` prompt, meant for tier selection rather than exact accounting. - A malformed rule fails open: it is skipped (never silently widened), and the rest of the rules still apply. - Routing is skipped for byte-faithful passthrough requests (`x-headroom-bypass: true` or `x-headroom-mode: passthrough`), so those are never model-rewritten. @@ -554,8 +557,11 @@ Configure context limits and pricing for new or custom models: }, "openai": { "context_limits": { - "gpt-5": 256000, - "ft:gpt-4o:my-org": 128000 + "ft:gpt-4o:my-org": 128000, + "my-private-deployment": 200000 + }, + "pricing": { + "my-private-deployment": [5.00, 15.00] } } } @@ -566,18 +572,61 @@ Save as `${HEADROOM_CONFIG_DIR}/models.json` (defaults to JSON string or file path. Installs that still have `~/.headroom/models.json` (the legacy location) continue to work. -Settings are resolved in this order (later overrides earlier): + + Configure **models Headroom can't already look up** — fine-tunes, private + deployments, gateway aliases. Public models are resolved from LiteLLM (see + below), so pinning one here means maintaining a number yourself that would + otherwise stay current on its own. + + +### Where limits and prices come from + +Headroom consults the **installed LiteLLM model database** (`litellm.model_cost`) +for public model metadata. That data ships with the LiteLLM package rather than +being fetched at runtime, so it is as current as your installed LiteLLM version +and refreshes when you upgrade it. Nothing here makes a network call. + +Limits and prices do **not** use the same precedence. Both are first-match-wins: + +**Context limits** + +1. **Explicit configuration and the built-in table**, checked together — they are + merged into one mapping, so an exact match in either returns immediately, then + partial/prefix matches. +2. **LiteLLM** (`max_input_tokens`) — reached only for models the previous step + didn't match. +3. **Pattern inference**, then a generic default. -1. Built-in defaults -2. `${HEADROOM_CONFIG_DIR}/models.json` (new canonical location); falls - back to `~/.headroom/models.json` (legacy) when the canonical file - is absent -3. `HEADROOM_MODEL_LIMITS` environment variable -4. SDK constructor arguments +**Pricing** + +1. **Explicit configuration** — a value you configured is a decision, so it beats + everything below. +2. **LiteLLM.** +3. **Built-in table**, then pattern inference, then a generic default. + + + The asymmetry is deliberate, not an oversight. For limits, a built-in entry + outranks LiteLLM because LiteLLM reports a model's maximum *capability* while + Headroom needs its *effective default*. `claude-sonnet-4-20250514` is the + concrete case: LiteLLM reports 1,000,000, but that window is an opt-in beta, so + the built-in 200,000 is the safe assumption for a client that has not enabled + it. Pricing has no equivalent capability-vs-default split, so there LiteLLM + wins outright. + + +Limits are an **input** budget (`max_input_tokens`), not the total window: `gpt-5` +resolves to 272K input, not the 400K total (272K in + 128K out). + +LiteLLM also resolves gateway-routed names — `azure/...`, `bedrock/...`, +`vertex_ai/...`, `groq/...` — which the built-in tables never covered. + +The built-in tables additionally cover installs where LiteLLM is unavailable: the +dependency is gated `python_version < '3.14'`. ### Pattern-Based Inference -Unknown models are automatically inferred from naming patterns: +Models that reach step 3 and match no table entry are inferred from naming +patterns: | Pattern | Inferred Settings | |---------|-------------------| @@ -585,7 +634,12 @@ Unknown models are automatically inferred from naming patterns: | `*sonnet*` | 200K context, Sonnet-tier pricing | | `*haiku*` | 200K context, Haiku-tier pricing | | `gpt-4o*` | 128K context, GPT-4o pricing | -| `o1*`, `o3*` | 200K context, reasoning model pricing | +| `gpt-4.1*` | ~1M context, GPT-4.1 pricing | +| `gpt-5*` | 272K input, GPT-5 pricing | +| `o1*`, `o3*`, `o4*` | 200K context, reasoning model pricing | + +Matching prefers the **most specific** prefix, so `gpt-4.1` is not treated as +`gpt-4` (which would give it an 8K limit and legacy `gpt-4` pricing). ## Provider-Specific Settings diff --git a/docs/content/docs/docker-install.mdx b/docs/content/docs/docker-install.mdx index 511bcae645..a25812c0a3 100644 --- a/docs/content/docs/docker-install.mdx +++ b/docs/content/docs/docker-install.mdx @@ -62,17 +62,24 @@ headroom memory list For `proxy`, the maintained wrapper always publishes the selected host port on loopback. It passes `--host 0.0.0.0` inside the container so that the loopback publication can reach the container, and it has no public-publication override. -For local-only use, no token is required: +For local-only use no token is needed, but the proxy refuses to start on a +non-loopback bind without one unless you acknowledge that the runtime already +restricts who can reach the port (here: the `127.0.0.1` publication): ```bash docker run --rm -it \ -p 127.0.0.1:8787:8787 \ + -e HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/headroomlabs-ai/headroom:latest \ --host 0.0.0.0 --port 8787 ``` +The maintained wrappers (`headroom install`, the `install.sh` / `install.ps1` +native wrappers, and `docker/docker-compose.native.yml`) set that acknowledgement +themselves, and only together with the `127.0.0.1` publication it depends on. + For deliberate public access, publish on an explicit public address and set `HEADROOM_PROXY_TOKEN`: diff --git a/docs/content/docs/hermes.mdx b/docs/content/docs/hermes.mdx new file mode 100644 index 0000000000..4d26aac8c1 --- /dev/null +++ b/docs/content/docs/hermes.mdx @@ -0,0 +1,59 @@ +--- +title: Hermes Agent +description: Give Hermes a native headroom_retrieve tool so compression markers are reversible, via the bundled hermes plugin. +--- + +Hermes Agent registers its own tools, so unlike Claude Code it does not automatically receive the `headroom_retrieve` MCP tool. The bundled [`plugins/hermes`](https://github.com/headroomlabs-ai/headroom/tree/main/plugins/hermes) plugin closes that loop: it gives Hermes a native `headroom_retrieve` tool that calls the proxy's `POST /v1/retrieve` endpoint directly, so compression markers like `<>` are no longer a black box. + +Without it, the model tends to re-run the original command (wasting tokens and time) or treat `ccr:abc123` as a file path and try to `cat` it. + +## Install + +1. Copy the plugin into Hermes's user plugin directory: + + ```bash + mkdir -p ~/.hermes/plugins + cp -r plugins/hermes/headroom_retrieve ~/.hermes/plugins/ + ``` + +2. Enable it in `~/.hermes/config.yaml`: + + ```yaml + toolsets: + - hermes-cli + - web + - headroom # add this + + plugins: + enabled: + - headroom_retrieve + ``` + + > Once the `plugins.enabled` key exists it acts as an explicit allowlist — list any other user plugins you already rely on. + +3. Restart the Hermes gateway / TUI (plugin discovery is cached per process). + +## Proxy configuration + +No configuration is required to prevent the retrieval loop on current headroom releases: `headroom_retrieve` is a protected built-in — it ships in `DEFAULT_EXCLUDE_TOOLS` and `DEFAULT_VERBATIM_EXCLUDE_TOOLS` (`headroom/config.py`), and the content router has dedicated guards that never recompress retrieved originals (issue #1077). Exclusion matching also unwraps Hermes's deferred `tool_call` bridge (`tool_search` / `tool_call` indirection), so the real tool name is matched even when Hermes invokes the plugin through the bridge. + +The one Hermes-specific exclusion worth considering is `read_file`: Hermes's tool names don't match Claude Code's `Read` / `Grep` / `Edit` that the built-in defaults protect, and file reads are reference data the agent needs verbatim: + +```bash +HEADROOM_EXCLUDE_TOOLS=read_file +``` + +Configured exclusions merge with (rather than replace) the built-in defaults, so this leaves every built-in protection — including the `headroom_retrieve` guards — intact. + +## Behavior + +- Accepts the bare hash or the whole marker — `<>`, `ccr:abc123`, and `hash=abc123` all normalize to `abc123`. +- Retrieval is by hash and always returns the full original content. +- Expired-hash (TTL) and proxy-unreachable errors tell the model to re-run the original command instead of retrying blindly. + +## Requirements + +- headroom proxy running on `127.0.0.1:8787` (edit `_PROXY_URL` in the plugin's `__init__.py` otherwise) +- `httpx` (already a Hermes dependency) + +Tested against headroom 0.22.4 and 0.23.0 with Hermes Agent on macOS and Linux. diff --git a/docs/content/docs/how-compression-works.mdx b/docs/content/docs/how-compression-works.mdx index 9b2fcf820a..6bba8b3e4b 100644 --- a/docs/content/docs/how-compression-works.mdx +++ b/docs/content/docs/how-compression-works.mdx @@ -35,10 +35,15 @@ The router auto-detects content type by analyzing structure and patterns. No man | Diffs | Unified diff format | DiffCompressor | 60-80% (measured ~70% on a multi-file diff with default `max_context_lines`/`max_hunks_per_file` limits) | | HTML | Tag structure | HTMLExtractor | 70-90% (module docstring; measured ~81% on a synthetic nav/ads/script-heavy page) | | Tabular (CSV/TSV/markdown tables) | Delimited rows / table syntax | TabularCompressor | Varies with redundancy -- TabularCompressor bridges to SmartCrusher, so it inherits the same range (measured 26-28% on non-duplicate rows) | +| Human-readable Git status | Branch/detached-HEAD header plus status sections and entries | Lossless passthrough (Python and Rust) | None: staged, unstaged, and untracked paths must remain intact | | Structured config (YAML/TOML/INI) | Config syntax | ConfigCompressor | 40-70% (measured ~50% on a commented/blank-line-heavy YAML file) | | Plain text | Text with no stronger signal | Kompress | workload-dependent | | Anything else | Fallback chain, then passthrough if no safe saving is found | Kompress / passthrough | varies | +Human-readable Git status is structured output, not prose: it is preserved byte-for-byte, including section labels and paths containing spaces. The router checks its structure before model classification and before selected external or lossy compressors, so CSV and wildcard plugins cannot trim status entries. This protection does not exclude all `Bash` results; ordinary prose and build logs still follow their normal routes. The recognizer covers the standard English status format (`LC_ALL=C`); localized formats are not covered. + +This protection also takes precedence over mixed-content splitting when a recognized status is followed by fenced code or JSON. The combined tool result is preserved rather than splitting status entries into prose sections. + ## Quick Start diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index fb856fc400..a11db86da4 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -24,6 +24,15 @@ dependencies may impose their own Python/platform limits. Platforms outside that matrix fall back to the source distribution and need a Rust/native toolchain. +On Intel macOS, ONNX Runtime wheels are available through Python 3.13, using +the pre-1.24 runtime. Python 3.14 and newer omit optional ONNX backends from +`proxy`, `voice`, `relevance`, and `image` extras because compatible wheels are +unavailable. Core compression and the proxy remain available; ML content +detection, local ONNX Kompress, embedding relevance, and ONNX OCR are unavailable +on that combination. Use Python 3.13 if you need those Python ONNX features. +Rust ML detection requires ONNX Runtime 1.24 or newer and therefore remains +disabled on Intel macOS even with the older Python runtime. + ### CLI install with uv For a host-level `headroom` command on macOS Apple Silicon or Linux, prefer @@ -99,7 +108,7 @@ pip install "headroom-ai[all]" | `langchain` | LangChain `HeadroomChatModel` wrapper; **not in `[all]`** | `pip install "headroom-ai[langchain]"` | | `agno` | Agno `HeadroomAgnoModel` wrapper; **not in `[all]`** | `pip install "headroom-ai[agno]"` | | `strands` | AWS Strands Agents integration; **not in `[all]`** | `pip install "headroom-ai[strands]"` | -| `crewai` | CrewAI integration; **not in `[all]`** | `pip install "headroom-ai[crewai]"` | +| *(none)* | CrewAI integration: no extra, install CrewAI alongside Headroom | `pip install headroom-ai crewai` | | `autogen` | AutoGen AgentChat integration; **not in `[all]`** | `pip install "headroom-ai[autogen]"` | | `anyllm` | any-llm multi-provider backend (Python 3.11+); **not in `[all]`** | `pip install "headroom-ai[anyllm]"` | | `bedrock` | Native AWS Bedrock credentials/backend support; **not in `[all]`** | `pip install "headroom-ai[bedrock]"` | diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index dc2e8b970d..ba7553294e 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -6,6 +6,7 @@ "installation", "docker-install", "persistent-installs", + "teams", "---Compression---", "how-compression-works", "smart-crusher", @@ -43,6 +44,10 @@ "opencode", "opencode-deepseek", "grok-build", + "hermes", + "oauth2-upstream", + "agent-hooks", + "openclaw", "mcp", "---Configuration---", "configuration", @@ -62,6 +67,8 @@ "runtime-rollouts", "benchmarks", "limitations", + "---Security---", + "security-model", "---Help---", "errors", "troubleshooting" diff --git a/docs/content/docs/oauth2-upstream.mdx b/docs/content/docs/oauth2-upstream.mdx new file mode 100644 index 0000000000..4627872462 --- /dev/null +++ b/docs/content/docs/oauth2-upstream.mdx @@ -0,0 +1,35 @@ +--- +title: OAuth2 Upstream Auth +description: Client-credentials bearer tokens for OpenAI-compatible enterprise backends, via the headroom-oauth2 proxy extension. +--- + +Enterprise AI gateways (Azure AD / Entra, Okta, Auth0, Keycloak, Cognito, …) often protect their OpenAI-compatible endpoints with an OAuth2 client-credentials flow. The [`headroom-oauth2`](https://github.com/headroomlabs-ai/headroom/tree/main/plugins/headroom-oauth2) extension mints a bearer token from a configurable token endpoint, caches and refreshes it (single-flight), and injects `Authorization: Bearer ` on each upstream request. It is fully vendor-neutral — no provider is hard-coded. + +It plugs into Headroom's public `headroom.proxy_extension` entry-point seam, so it is fully out-of-tree and opt-in. + +## Install & enable + +```bash +pip install headroom-oauth2 +headroom proxy --backend litellm-openai --proxy-extension oauth2 +``` + +## Configuration + +Environment variables (no-op unless `HEADROOM_OAUTH2_TOKEN_URL` is set): + +| Env | Meaning | +|-----|---------| +| `HEADROOM_OAUTH2_TOKEN_URL` | token endpoint (client_credentials grant) | +| `HEADROOM_OAUTH2_CLIENT_ID` / `_CLIENT_SECRET` | credentials | +| `HEADROOM_OAUTH2_SCOPES` | space/comma-separated scopes | +| `HEADROOM_OAUTH2_AUDIENCE` | optional audience | +| `HEADROOM_OAUTH2_GRANT_TYPE` | default `client_credentials` | +| `HEADROOM_OAUTH2_AUTH_STYLE` | `post` (form creds) or `basic` (HTTP Basic) | +| `HEADROOM_OAUTH2_HEADERS` | static upstream headers, `K=V,K2=V2` | + +## Notes + +- **Effective backends**: the injected bearer reaches the upstream only for OpenAI-compatible / passthrough litellm providers. `bedrock` / `vertex` / `sagemaker` authenticate from env and ignore it — the extension is a no-op there (it logs a warning at startup). +- **Transport**: `token_url` must be `https` (loopback `http` allowed for tests; set `HEADROOM_OAUTH2_ALLOW_INSECURE=1` to override). +- **TLS**: tokens are minted with the standard library (`urllib`, system cert store), so a corporate-injected CA is trusted without bundling roots — this works behind corporate SSL-inspection where bundled-root TLS stacks fail. diff --git a/docs/content/docs/openclaw.mdx b/docs/content/docs/openclaw.mdx new file mode 100644 index 0000000000..19f8f42b0b --- /dev/null +++ b/docs/content/docs/openclaw.mdx @@ -0,0 +1,80 @@ +--- +title: OpenClaw +description: Context compression plugin for OpenClaw with 70-90% token savings and optional local proxy auto-start. +--- + +The bundled [`plugins/openclaw`](https://github.com/headroomlabs-ai/headroom/tree/main/plugins/openclaw) plugin (`@headroom-ai/openclaw`) brings Headroom compression to [OpenClaw](https://github.com/openclaw/openclaw): tool outputs, code, logs, and structured data — 70-90% token savings with zero LLM calls. + +## Install + +Recommended one-command setup: + +```bash +headroom wrap openclaw +``` + +Manual install: + +```bash +pip install "headroom-ai[proxy]" +openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw +``` + +> The plugin can auto-start a local `headroom proxy` when needed. Auto-start is opt-in via `autoStart` (default `false` — see [Configure](#configure)). OpenClaw treats process-launching plugins as unsafe by default, so `--dangerously-force-unsafe-install` is required even if you plan to use a remote proxy — the capability is declared at install time. + +## Configure + +Install automatically selects the `contextEngine` slot for `headroom` on current OpenClaw releases. To switch back manually, set `plugins.slots.contextEngine` to `"legacy"` or another engine id. + +Key options (exposed as plugin UI hints): + +| Option | Default | Meaning | +|--------|---------|---------| +| `proxyUrl` | auto-detected | Optional proxy URL (e.g. `http://127.0.0.1:8787` or remote). Probe-gated before provider routing. Remote URLs are connect-only: probed at startup, no subprocess spawned. | +| `proxyPort` | `8787` | Default port for auto-detect/auto-start when `proxyUrl` is unset. | +| `pythonPath` | auto-detected | Optional explicit python executable for the python fallback launcher. | +| `autoStart` | `false` | Opt-in auto-start of a local `headroom proxy` if none is detected (local URLs only; ignored for remote proxies). Keep `false` when systemd or another supervisor owns the proxy. | +| `startupTimeoutMs` | `20000` | Time to wait for an auto-started proxy to become healthy. | +| `retryMaxAttempts` | unset | Max upstream retry attempts (connection/read/5xx) with an auto-started local proxy. | +| `connectTimeoutSeconds` | unset | Upstream connection timeout for the auto-started proxy. | +| `requestTimeoutMs` | `30000` | Max wait for one compression request; on timeout the call is cancelled and the original uncompressed messages are used as a fallback. | +| `circuitBreakerThreshold` | `3` | Consecutive `assemble()` errors before the circuit breaker opens and all requests bypass the proxy. | +| `circuitBreakerCooldownMs` | `60000` | How long the circuit breaker stays open; after the cooldown it resets and the next request re-probes the proxy via `/health`. | +| `routeCodexViaProxy` | `true` | Rewrite OpenClaw's built-in `openai-codex` provider base URL to the active proxy in memory (see below). | +| `gatewayProviderIds` | `[]` | Explicit list of OpenClaw provider ids to route through the proxy. Aliases `codex`, `claude`, `copilot`, `gemini` are accepted; when set, it overrides the default `openai-codex` routing list. | + +### Upstream gateway routing + +By default (`routeCodexViaProxy: true`) the plugin rewrites the built-in `openai-codex` provider base URL to a verified active proxy at runtime, in memory — so Codex provider traffic flows through Headroom and `/stats` can observe real upstream request and cache activity instead of only local context compression. This does not replace the proxy's own Codex routing rules (it still decides between `api.openai.com` and `chatgpt.com/backend-api/codex/responses` based on ChatGPT auth); the plugin only points the provider config at the active proxy and preserves the rest. + +To route additional providers through the same proxy, set `gatewayProviderIds`: + +```json +{ + "plugins": { + "entries": { + "headroom": { + "enabled": true, + "config": { + "gatewayProviderIds": ["openai-codex", "anthropic", "github-copilot", "google", "openrouter"] + } + } + } + } +} +``` + +The rewrite is intentionally lightweight and reversible: provider `baseUrl` changes are never persisted back to `openclaw.json`, and disabling the plugin, clearing `gatewayProviderIds`, or restarting without Headroom restores normal provider resolution. Set `routeCodexViaProxy: false` to disable the default Codex routing; for durable provider rewrites use `headroom wrap openclaw` instead of relying on plugin install side effects. + +## Local development install + +When testing from the repo, build from the plugin directory so launcher detection aligns with runtime paths: + +```bash +cd plugins/openclaw +npm install +npm run build +openclaw plugins install --dangerously-force-unsafe-install --link . +``` + +The plugin checks launchers in this order: **PATH → local npm bin → global npm → python**, where "local npm bin" means `plugins/openclaw/node_modules/.bin/headroom` relative to the source checkout. `plugins/openclaw` also carries a no-op hook shim so OpenClaw's hook-pack fallback treats the path as valid instead of emitting a misleading `package.json missing openclaw.hooks` warning. diff --git a/docs/content/docs/opencode-deepseek.mdx b/docs/content/docs/opencode-deepseek.mdx index b649222e32..f51bc832a4 100644 --- a/docs/content/docs/opencode-deepseek.mdx +++ b/docs/content/docs/opencode-deepseek.mdx @@ -201,9 +201,57 @@ API key to the proxy, and the proxy forwards it to DeepSeek. Make sure ### Models appear but requests fail -You ran `headroom wrap opencode`. That command replaces your config with Claude -and GPT models. **Do not use `headroom wrap`.** Configure OpenCode manually as -shown above, and launch OpenCode directly with `opencode`. +You ran `headroom wrap opencode` without telling it about DeepSeek. Without an +explicit upstream the proxy it starts forwards OpenAI-compatible traffic to +OpenAI, so wrap leaves the `apiKey` and `models` entries from step 4 out of the +`headroom` provider rather than send your DeepSeek key there. Pass +`--openai-api-url` as shown in the next section, or launch OpenCode directly +with `opencode` against the proxy you started in step 3. + +### I used `headroom wrap opencode` and get "Incorrect API key provided" + +``` +Incorrect API key provided: sk-13005***********************898d. +You can find your API key at https://platform.openai.com/account/api-keys. +``` + +That 401 comes from **OpenAI**, not DeepSeek. `headroom wrap opencode` starts +the proxy for you, and unless it is told otherwise the proxy forwards +OpenAI-compatible traffic to its default upstream, `https://api.openai.com/v1`. +Your DeepSeek key is then presented to OpenAI, which rejects it and points you +at a dashboard for a key you never had. + +Pass the upstream to wrap, exactly like `headroom proxy --openai-api-url`: + +```bash +headroom wrap opencode --openai-api-url https://api.deepseek.com/v1 +``` + +The equivalent environment variable also works, and is honored by both +commands: + +```bash +OPENAI_TARGET_API_URL=https://api.deepseek.com/v1 headroom wrap opencode +``` + +If a proxy is already running on the port with a different upstream, stop it +first (`headroom unwrap opencode`) — wrap will not silently re-point a proxy +that other clients may be sharing. + +### When is `headroom wrap opencode` safe? + +- **Safe as-is** when your traffic goes to the default upstreams — OpenAI + (`api.openai.com`) or Anthropic (`api.anthropic.com`). +- **Needs `--openai-api-url`** (or `OPENAI_TARGET_API_URL`) for any third-party + OpenAI-compatible provider: DeepSeek, Together, OpenRouter, Groq, a + self-hosted gateway, and so on. +- **Keep the step 4 provider entries.** OpenCode only offers the + `headroom/` models listed under `provider.headroom.models`, and DeepSeek + needs your key in `options.apiKey` (for example `"{env:DEEPSEEK_API_KEY}"`). + With `--openai-api-url`, wrap keeps both. It rewrites the provider's `npm`, + `name` and `baseURL` and adds its default `gpt-4o` and `gpt-4.1` ids next to + your models. Without the flag it leaves your key and models out, because the + proxy would forward them to OpenAI. ### "headroom" command not found @@ -231,7 +279,6 @@ shaper is active immediately — the numbers just need calibration. ## What's NOT in this guide - **Claude or GPT models** — this setup uses DeepSeek exclusively -- **`headroom wrap`** — do not use it; it overrides the config - **Deprecated model names** — `deepseek-chat` and `deepseek-reasoner` are compatibility aliases slated for removal; use `deepseek-v4-pro` and `deepseek-v4-flash` instead diff --git a/docs/content/docs/opencode.mdx b/docs/content/docs/opencode.mdx index 7ce257f880..bbb8c43498 100644 --- a/docs/content/docs/opencode.mdx +++ b/docs/content/docs/opencode.mdx @@ -41,6 +41,7 @@ headroom wrap opencode \ --no-serena \ --code-graph \ --no-proxy \ + --openai-api-url https://api.deepseek.com/v1 \ --learn \ --memory \ --backend anthropic \ @@ -49,6 +50,32 @@ headroom wrap opencode \ -- ``` +Pass `--openai-api-url` when OpenCode should route through a third-party +OpenAI-compatible provider instead of OpenAI itself — DeepSeek, Together, +OpenRouter, or a self-hosted gateway. Without it, OpenAI-compatible traffic is +forwarded to the default upstream (`https://api.openai.com/v1`), so a +non-OpenAI key is rejected with `401 Incorrect API key provided`. Setting +`OPENAI_TARGET_API_URL` before wrapping has the same effect. See +[OpenCode + DeepSeek](/docs/opencode-deepseek) for a full walkthrough. + +The flag means the same thing whether or not wrap starts the proxy: + +```bash +# wrap starts the proxy and points it at DeepSeek +headroom wrap opencode --openai-api-url https://api.deepseek.com/v1 + +# reuse a proxy you started yourself — it must already target that upstream +headroom proxy --port 8787 --openai-api-url https://api.deepseek.com/v1 +headroom wrap opencode --no-proxy --openai-api-url https://api.deepseek.com/v1 +``` + +With `--no-proxy`, wrap never starts or re-points a proxy. It checks the one +listening on `--port` and exits with an error — before it rewrites +`opencode.json` — if nothing is listening, the listener is not a Headroom +proxy, or it forwards OpenAI-compatible traffic to a different upstream. Start +the proxy with the upstream you want, or drop `--no-proxy` and let wrap start +it. + ## Provider Model Mapping The generated `headroom` provider exposes these models through the proxy: @@ -69,6 +96,7 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json` |---|---| | `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, plugin, and optional local MCP config injected by `wrap` | | `HEADROOM_PROXY_URL` | Proxy URL passed to Headroom MCP when a non-default port is used, and to the native plugin when configured | +| `OPENAI_TARGET_API_URL` | Upstream for OpenAI-compatible traffic when `--openai-api-url` is not passed; defaults to `https://api.openai.com/v1` | | `HEADROOM_OPENCODE_EXCLUDE_HOSTS` | Comma-separated hosts (and their subdomains) the native plugin sends straight upstream instead of through the proxy, for example `opencode.ai,.corp.internal` | ## Failure Learning @@ -95,20 +123,22 @@ The default user scope only writes shell environment configuration. For OpenCode ## Native OpenCode Plugin -The `headroom-opencode` package exports `HeadroomPlugin` for direct OpenCode plugin registration. The plugin installs Headroom transport interception inside OpenCode, exposes the `headroom_retrieve` tool, and publishes Headroom metadata through the OpenCode plugin output env. +The `headroom-opencode` package's default export is a native OpenCode plugin that works with both OpenCode 1.x and 2.x. It installs Headroom transport interception inside OpenCode, exposes the `headroom_retrieve` tool, and adds Headroom metadata (`HEADROOM_ACTIVE`, `HEADROOM_PROXY_URL`, `HEADROOM_PROJECT`) to the environment of shell commands OpenCode runs. -Example: +Register it in `opencode.json`: -```ts -import { HeadroomPlugin } from "headroom-opencode"; - -export default async function plugin(input) { - return HeadroomPlugin(input, { - proxyUrl: process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787", - }); +```json +{ + "plugin": [ + ["headroom-opencode", { "proxyUrl": "http://127.0.0.1:8787" }] + ] } ``` +OpenCode 2.x migrates the 1.x `plugin` key automatically. To configure it the 2.x way, use `"plugins": [{ "package": "headroom-opencode", "options": { "proxyUrl": "http://127.0.0.1:8787" } }]`. Without `proxyUrl`, the plugin reads `HEADROOM_PROXY_URL` and falls back to `http://127.0.0.1:8787`. + +The default export has `server` for OpenCode 1.x and `setup` for OpenCode 2.x. Each version ignores the other's field. `HeadroomPlugin` (1.x) and `headroomSetup` (2.x) are also exported by name for custom wrappers. + Use this plugin when OpenCode should intercept provider traffic in-process. Use `headroom wrap opencode` when you want the CLI to manage the proxy, config injection, MCP registration, backups, and unwrap behavior. The plugin routes every external HTTP(S) request through the proxy. To keep a provider or gateway off the proxy while leaving the plugin loaded, pass `excludeHosts` in the plugin options or set `HEADROOM_OPENCODE_EXCLUDE_HOSTS` before launching OpenCode. An entry matches the host and all of its subdomains; excluded requests are sent as-is, with no compression or telemetry. @@ -146,7 +176,7 @@ const retrieve = createHeadroomRetrieveTool({ 1. **Config injection**. The wrapper writes a `provider.headroom` block into `opencode.json`. The provider uses `@ai-sdk/openai-compatible`, which OpenCode supports natively. Model mappings route requests through `http://127.0.0.1:/v1`. 2. **Runtime config**. `OPENCODE_CONFIG_CONTENT` is set as an env var containing provider, plugin, and optional local MCP JSON. OpenCode reads it at startup and merges it with on-disk config. 3. **MCP tools**. Headroom registers `headroom_compress`, `headroom_retrieve`, and `headroom_stats` through `headroom mcp serve` unless `--no-mcp` is set. -4. **Native plugin path**. `HeadroomPlugin` installs Headroom transport interception and uses `HEADROOM_PROXY_URL` or `http://127.0.0.1:8787` to reach the proxy. +4. **Native plugin path**. `wrap` loads the transport plugin from a local directory (`plugins/opencode/dist/` in a checkout, `headroom/providers/opencode/_dist/` in the wheel), because OpenCode 2.x only accepts local plugins as directories. The plugin uses `HEADROOM_PROXY_URL` or `http://127.0.0.1:8787` to reach the proxy. 5. **Unwrap**. `headroom unwrap opencode` restores `opencode.json` from the pre-wrap backup when present, strips Headroom marker blocks when no backup exists, and unregisters Headroom MCP servers. ## Troubleshooting @@ -155,6 +185,10 @@ const retrieve = createHeadroomRetrieveTool({ Check that `OPENCODE_CONFIG_CONTENT` is set and contains the `provider.headroom` block. The wrap command prints the env vars it sets. +**OpenCode 2.x traffic does not show up in Headroom.** + +OpenCode 2.x connects to a shared background service by default. A service that was already running when you wrapped never sees Headroom's config. `headroom wrap opencode` adds `--standalone` on 2.x so OpenCode runs a private server with that config instead. If you pass `--server `, that server must be started with Headroom's config too. Check what OpenCode actually loaded with `opencode api plugin.list --param directory="$PWD"`, which should list `headroom`. + **The native plugin cannot reach Headroom.** Set `HEADROOM_PROXY_URL` to the running proxy URL, for example `http://127.0.0.1:8787`. diff --git a/docs/content/docs/persistent-installs.mdx b/docs/content/docs/persistent-installs.mdx index fd1c331955..93866b71b8 100644 --- a/docs/content/docs/persistent-installs.mdx +++ b/docs/content/docs/persistent-installs.mdx @@ -90,6 +90,8 @@ headroom install remove `apply` creates or updates a named deployment profile, stores its manifest under `~/.headroom/deploy//manifest.json`, applies reversible configuration changes, and starts the selected runtime. +`status` distinguishes the default backend from the Anthropic and OpenAI protocol targets. When the health response includes a target's configuration, that target is shown as `configured` or `default`. If configuration is unavailable or omits a target, that target is shown as `unknown`; this does not mean it is unset. For example, a Docker bridge peer may not pass the loopback health-config gate. Target URLs and credentials are not printed. An OpenAI-compatible deployment can therefore have `Default backend: anthropic` and `OpenAI target: configured` at the same time. + ## Presets and runtime kinds ### Presets @@ -122,6 +124,37 @@ Provider scope is intentionally conservative. The current direct adapters are: - OpenClaw → existing `wrap openclaw` / `unwrap openclaw` flow - OpenCode → managed block in `~/.config/opencode/opencode.json` +For OpenCode provider scope, reapplying the deployment, restarting it, or +recovering a stopped proxy removes and reinjects only the managed `headroom` +provider. Later edits to themes, other providers, and MCP configuration stay +intact. JSONC input may contain comments and trailing commas. +Active configuration is serialized as JSON; original formatting and comments +are preserved in the snapshot. The original `.headroom-backup` +is kept across these transitions for both `.json` and `.jsonc` configurations. +Final `headroom install remove` restores that original snapshot and deletes the +backup, including after a stop and manifest reload. + +Existing JSONC snapshots using the older `.json.headroom-backup` filename +are migrated only when the canonical backup is absent. When a sibling `.json` +config or a canonical snapshot already exists, Headroom preserves the ambiguous +legacy backup in place and creates a `.json.headroom-backup.jsonc-migration-blocked` +marker. This marker survives removal and prevents later installs from claiming +the legacy backup, even after the conflicting config or snapshot disappears. +The legacy bytes remain available for manual recovery; copy them to the intended +config only after inspecting them. Keep the marker to prevent automatic migration. +If the marker cannot be created, the operation fails before changing the config +or consuming its canonical snapshot. + +Backup ownership follows the config path recorded in the deployment, not a +later `OPENCODE_CONFIG` value. A successful replacement restores snapshots for +retired config paths and transfers any pending restoration to the new manifest. +If restoration fails, the replacement remains active; resolve the filesystem +error and retry `headroom install apply` to complete it. + +Failed replacement cleanup consumes snapshots newly created by that attempt, +but preserves snapshots owned by the previous deployment for recovery. + + For Copilot, Aider, Cursor, and broader env-driven setups, prefer `--scope user` or `--scope system`. ## Provider selection diff --git a/docs/content/docs/proxy.mdx b/docs/content/docs/proxy.mdx index 4864b1318c..32dbb89347 100644 --- a/docs/content/docs/proxy.mdx +++ b/docs/content/docs/proxy.mdx @@ -36,28 +36,7 @@ Separately, an **anonymous usage beacon is on by default** (opt-out). Once a ses The install id is a random UUID generated on first run and stored in your config directory. It is deliberately not derived from hostname, MAC address, or any hardware property — delete the file and you get a new one. -Disable it with `HEADROOM_BEACON=off`, the `DO_NOT_TRACK=1` convention, or `--offline` (which also disables update checks and license reporting). - -### Listening on a unix socket - -`headroom proxy --uds /path/to/dir/proxy.sock` serves HTTP on a unix domain socket and binds no TCP port. A local process cannot reach the proxy just by grabbing a free loopback port; only processes allowed by the filesystem can connect. The same flag exists on `python -m headroom.proxy.server`, and programmatic callers set `ProxyConfig(uds=...)`. - -- The parent directory must already exist, be owned by the user running the proxy, and not be writable by group or others (`mkdir -m 700` is the recommended setup). Whoever can write a directory can delete the socket in it and bind their own at the same path, so a directory that fails this check is refused with a one-line error before the banner. A sticky bit (as on `/tmp`) does not substitute: it stops other users removing your entries but not planting one at the path first. Only the parent is inspected, not the directories above it, and Headroom never creates or chmods it. -- The socket is created with mode `0600`. -- A relative path is resolved against the current directory and `~` is expanded before anything else, and the banner and the `curl --unix-socket` example show the resolved path. The resolved path must fit the platform's `sockaddr_un` limit (`sun_path` is 104 bytes on macOS and the BSDs and 108 on Linux; the usable length is measured at startup); a longer one is refused with an error naming its length and the limit. -- `--uds` is refused on a platform without unix domain sockets, such as Windows. -- A socket left at the path by a process that has exited is replaced. A socket another process is still listening on, or any path that is not a socket, is refused and the proxy exits. -- The socket file is removed on shutdown (Ctrl+C or SIGTERM), unless something else has replaced it in the meantime. -- `--uds` cannot be combined with `--host`, `--port`, `HEADROOM_HOST` or `HEADROOM_PORT`. -- Peers on the socket count as local for the loopback-only `/debug/*` and dashboard routes and are exempt from `HEADROOM_PROXY_TOKEN`, exactly like loopback TCP callers. The `x-headroom-user-id` header is the exception: uvicorn reports no peer address on a unix socket, and identity selection only trusts a peer it can see is loopback, so the header from a socket caller is ignored. -- Per-instance state that is normally keyed by port (the runtime log, the beacon lock, the embedding sidecar's default socket) is keyed by a hash of the socket path instead. -- `HEADROOM_CC_SWITCH_RECONCILE` is refused, since it points Claude Code at a TCP URL. - -```bash -mkdir -m 700 ~/.headroom-run -headroom proxy --uds ~/.headroom-run/proxy.sock -curl --unix-socket ~/.headroom-run/proxy.sock http://localhost/health -``` +Disable it with `HEADROOM_BEACON=off`, the `DO_NOT_TRACK=1` convention, or `HEADROOM_OFFLINE=1` (which also disables update checks and license reporting). ## CLI options @@ -67,7 +46,7 @@ curl --unix-socket ~/.headroom-run/proxy.sock http://localhost/health |--------|---------|-------------| | `--host` | `127.0.0.1` | Host to bind to | | `--port` | `8787` | Port to bind to | -| `--uds` | None | Listen on this unix domain socket path instead of `--host`/`--port` (see [Listening on a unix socket](#listening-on-a-unix-socket)) | +| `--uds` | None | Serve on a Unix domain socket at this path instead of `--host`/`--port`. POSIX only — Windows has no `AF_UNIX` support in Python or asyncio. See [Serving on a Unix socket](#serving-on-a-unix-socket) | | `--workers` | `1` | Number of Uvicorn worker processes | | `--limit-concurrency` | `1000` | Maximum concurrent connections before Uvicorn returns 503 | | `--max-connections` | `500` | Maximum upstream HTTP connections | @@ -113,6 +92,8 @@ Avoid setting process-wide variables such as `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_P `headroom wrap` registers **[Serena](https://github.com/oraios/serena)** as the code-memory MCP for semantic, symbol-level code navigation. Serena runs on demand via `uvx` — Headroom downloads and executes no binary of its own — and indexes the current project locally. Pass `--code-memory none` to register no code-memory MCP. +For Claude Code, the entry is written at `local` scope, which means it applies to the directory you wrapped and nothing else. That keeps a Serena that cannot start from breaking Claude Code sessions in unrelated projects. Pass `--code-memory-scope user` (or set `HEADROOM_CODE_MEMORY_SCOPE=user`) to register it machine-wide instead, which is how releases before this change behaved. If an earlier version already registered Serena machine-wide, the next `headroom wrap claude` moves it into the project scope for you — but only when Headroom installed that entry itself; a Serena you registered by hand is left alone. + > **Upgrading from tokensave?** Earlier releases registered a `tokensave` MCP server (a downloaded Rust binary). tokensave has been retired in favour of Serena. On your next `headroom wrap` / `headroom unwrap`, Headroom removes the `tokensave` MCP entry it installed and switches you to Serena — nothing to migrate, since both are just indexes rebuilt from your source. The leftover `tokensave` binary in `~/.local/bin` and any `.tokensave/` folders are unused and safe to delete. By default, the proxy uses the shared **ContentRouter** pipeline. It routes text, logs, JSON, code, images, and tool outputs through the currently enabled compressors and preserves reversible CCR markers where applicable. @@ -138,7 +119,7 @@ headroom proxy --mode cache An unrecognized `HEADROOM_SAVINGS_PROFILE` value logs a warning and falls back to `coding` (the same default an unset variable resolves to) — the proxy never fails to start over a bad profile name. `balanced` is used only as a last-resort fallback when the runtime has no `coding` entry at all (old-runtime/new-client version skew). See `headroom/agent_savings.py` for each profile's full set of knobs. -Because the default `coding` profile uses **cache** mode (and the proxy's own default mode is also `cache`), Headroom runs in cache mode out of the box. Mode precedence: an explicit `--mode` wins, otherwise `HEADROOM_MODE` (which a profile seeds), otherwise the `cache` default. To run token mode, pass `--mode token` or choose a token-mode profile: +Because the default `coding` profile uses **cache** mode (and the proxy's own default mode is also `cache`), Headroom runs in cache mode out of the box. For a direct `headroom proxy` invocation, mode precedence is `--mode` > `HEADROOM_MODE` > the `cache` default. The selected `HEADROOM_SAVINGS_PROFILE` does not provide a third-level mode fallback after startup; wrapped launches may seed `HEADROOM_MODE` before starting the proxy. To run token mode, pass `--mode token` or set `HEADROOM_MODE=token`: ```bash # Aggressive ~90% token-savings profile @@ -149,7 +130,7 @@ HEADROOM_SAVINGS_PROFILE=agent-90 headroom proxy --port 8787 | Option | Default | Description | |--------|---------|-------------| -| `--memory` | `false` | Enable persistent user memory and provider-appropriate memory tools | +| `--memory` | `false` | Enable persistent user memory and provider-appropriate memory tools. In `--mode cache`, automatic context injection is skipped only for Anthropic `/v1/messages` to preserve prefix stability; OpenAI chat/responses and Gemini append memory to the live-zone tail. Use `--mode token` for automatic memory context on the Anthropic path. | | `--memory-db-path` | `{cwd}/.headroom/memory.db` | Override the memory SQLite path | | `--no-memory-tools` | `false` | Disable automatic memory tool injection | | `--no-memory-context` | `false` | Disable automatic memory context injection | @@ -197,17 +178,20 @@ HEADROOM_SAVINGS_PROFILE=agent-90 headroom proxy **`general`** — Token-mode compression for non-coding conversations. No turn protection (`protect_recent=0` — nothing code-positional to preserve) and does not compress user or system messages. Uses the standard lossless pipeline. -### Profiles override CLI flags +### Profile defaults and overrides -A profile's `proxy_mode` setting overrides the `--mode` flag. The `coding` profile sets `proxy_mode="cache"`, so `--mode token` has **no effect** when coding is active: +Profiles seed compression settings. For a direct `headroom proxy` invocation, explicit mode settings take precedence in this order: `--mode` (CLI) > `HEADROOM_MODE` (environment) > the `cache` default. The selected profile does not replace that final fallback, but wrapped launches may pre-seed `HEADROOM_MODE`. The `coding` profile defaults to `cache`, and an explicit `--mode token` still takes effect: ```bash -# These are equivalent — coding's cache mode always wins +# Coding's default mode is cache headroom proxy -headroom proxy --mode token # --mode token is silently overridden +# An explicit CLI mode overrides the profile default +headroom proxy --mode token +# An explicit environment mode also overrides the profile default +HEADROOM_MODE=token HEADROOM_SAVINGS_PROFILE=coding headroom proxy ``` -To run in token mode, switch to a profile that uses it: +You can also choose a profile whose default is token mode: ```bash HEADROOM_SAVINGS_PROFILE=agent-90 headroom proxy --mode token @@ -229,6 +213,57 @@ HEADROOM_SAVINGS_PROFILE=balanced HEADROOM_TARGET_RATIO=0.15 headroom proxy For permanent custom profiles, see the profile definitions in `headroom/agent_savings.py`. Each profile is an `AgentSavingsProfile` dataclass with fields for compression mode, target ratio, turn protection, and pipeline toggles. +## Serving on a Unix socket + +`--uds` binds an `AF_UNIX` socket instead of a TCP port. Request handling is +identical — only the transport changes. The same flag exists on +`python -m headroom.proxy.server`, and programmatic callers set `ProxyConfig(uds=...)`. + +```bash +mkdir -m 700 ~/.headroom-run +headroom proxy --uds ~/.headroom-run/proxy.sock +``` + +Two reasons to prefer it over a loopback port: + +- **No port to collide with, and no port exposed.** Access is governed by + filesystem permissions rather than by anything listening on the network, so a + local process cannot reach the proxy just by grabbing a free loopback port. +- **Deployments where a port is awkward.** Containers and systemd units can share a + socket through a bind mount or a runtime directory without publishing a port. + + + A socket does **not** re-enable Claude Code's Remote Control. Setting + `ANTHROPIC_UNIX_SOCKET` passes that feature's host check but makes Claude Code + treat the session as API-key auth, which fails a separate subscription-auth + requirement — see [Why the Unix socket route does not + help](/docs/troubleshooting#why-the-unix-socket-route-does-not-help). + + +Point any Unix-socket-capable client at the path. With curl: + +```bash +curl --unix-socket ~/.headroom-run/proxy.sock http://localhost/health +``` + +### Permissions and lifecycle + +- The parent directory must already exist, be owned by the user running the proxy, and not be writable by group or others (`mkdir -m 700` is the recommended setup). Whoever can write a directory can delete the socket in it and bind their own at the same path, so a directory that fails this check is refused with a one-line error before the banner. A sticky bit (as on `/tmp`) does not substitute: it stops other users removing your entries but not planting one at the path first. Only the parent is inspected, not the directories above it, and Headroom never creates or chmods it. +- The socket is created with mode `0600`, before the proxy starts accepting connections on it. +- A relative path is resolved against the current directory and `~` is expanded before anything else, and the banner and the `curl --unix-socket` example show the resolved path. The resolved path must fit the platform's `sockaddr_un` limit (`sun_path` is 104 bytes on macOS and the BSDs and 108 on Linux; the usable length is measured at startup); a longer one is refused with an error naming its length and the limit. +- A socket left at the path by a process that has exited is replaced. A socket another process is still listening on (two proxies would silently split traffic), or any path that is not a socket (far more likely a mistyped argument pointing at real data than a leftover), is refused and the proxy exits. +- The socket file is removed on shutdown (Ctrl+C or SIGTERM), unless something else has replaced it in the meantime. +- `--uds` cannot be combined with `--host`, `--port`, `HEADROOM_HOST` or `HEADROOM_PORT`. +- Peers on the socket count as local: uvicorn reports no peer address on a unix socket, so the proxy gives each request on it the loopback address `127.0.0.1`. They reach the loopback-only `/debug/*`, `/admin` and dashboard routes, are exempt from `HEADROOM_PROXY_TOKEN`, and have their `x-headroom-user-id` header honoured, exactly like loopback TCP callers. Only the proxy's own user can connect to the socket, which is narrower than loopback, where any local user can. +- Per-instance state that is normally keyed by port (the runtime log, the beacon lock, the embedding sidecar's default socket) is keyed by a hash of the socket path instead. +- `HEADROOM_CC_SWITCH_RECONCILE` is refused, since it points Claude Code at a TCP URL. + + + `--uds` is POSIX-only. On Windows the command exits immediately with an error + telling you to use `--port`; Python exposes no `socket.AF_UNIX` there and + asyncio has no Windows UDS transport. + + ## Configuration in depth Proxy behavior is set by three layers, **each overriding the one before**: @@ -248,7 +283,7 @@ Fine-grained control over what gets compressed and how hard. Most users pick a [ | `--mode` / `HEADROOM_MODE` | `cache` | `cache` compresses only the newest delta (prefix-cache safe); `token` maximizes removal. | | `--target-ratio` / `HEADROOM_TARGET_RATIO` | unset | Keep-ratio for ML text compression; lower = more aggressive (e.g. `0.10`). | | `HEADROOM_MIN_TOKENS` | `500` | Minimum block size before a tool output is compressed. | -| `HEADROOM_COMPRESS_USER_MESSAGES` | `false` | Compress content inside user-role messages (tool results live there). The `coding` profile turns this on. No `--compress-user-messages` flag on `headroom proxy`; the direct module entry point (`python -m headroom.proxy.server`) does accept one. | +| `HEADROOM_COMPRESS_USER_MESSAGES` | profile | Compress text inside user-role messages. The `coding` profile turns this on; set `0` to keep user text verbatim under it. Anthropic `tool_result` blocks compress either way. No `--compress-user-messages` flag on `headroom proxy`; the direct module entry point (`python -m headroom.proxy.server`) does accept one. | | `HEADROOM_COMPRESS_SYSTEM_MESSAGES` | unset | Compress system prompts. Off by default to keep the hottest cache prefix stable. | | `HEADROOM_PROTECT_RECENT` | profile | Never compress the N most recent turns. | | `--protect-tool-results` / `HEADROOM_PROTECT_TOOL_RESULTS` | empty | Comma-separated tool names whose output is never lossy-compressed. | @@ -285,7 +320,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` | *(Beta)* Hold freshly-read files out of the prefix cache until the file quiesces. Requires `HEADROOM_ROLLOUT_CHANNEL=beta` (or `dev`). | +| `--read-maturation` / `HEADROOM_READ_MATURATION` | `false` | *(Beta)* Keep freshly-read files verbatim while in use, then compress them once the file quiesces, unless the provider cache already holds them. 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 @@ -355,9 +390,12 @@ with a privileged job. | Flag / env | Default | Effect | |---|---|---| -| `HEADROOM_PROXY_TOKEN` | none | Require a bearer token (`X-Headroom-Proxy-Token`) from non-loopback callers. | +| `HEADROOM_PROXY_TOKEN` | none | Require a token from non-loopback callers, as `X-Headroom-Proxy-Token: ` or `Authorization: Bearer `. The token is removed from the request before it is forwarded upstream; clients that also send a provider credential should use `X-Headroom-Proxy-Token` and keep `Authorization` for the provider. Also gates the read-only operator routes (`/stats-history`, `/quota`, `/subscription-window`, `/metrics`), which otherwise answer loopback and trusted-CIDR callers only. | +| `HEADROOM_ALLOW_UNAUTHENTICATED_BIND` | `false` | Acknowledge a non-loopback bind (`--host 0.0.0.0`, `::`, a LAN address) with **no** `HEADROOM_PROXY_TOKEN`. Without a token or this flag the proxy refuses to start, because `/v1/*` would relay upstream with the operator's credentials for every peer that can reach the port. Only set it when the runtime already restricts reachability (a container published on `127.0.0.1`). | | `HEADROOM_COMPRESS_ALLOW_REMOTE` | `false` | Allow non-loopback callers to reach [`POST /v1/compress`](#post-v1compress) and `POST /v1/usage`. Required to run Headroom as a remote gateway/sidecar; without it remote callers get `404`. | -| `--offline` / `HEADROOM_OFFLINE` | `false` | Air-gap mode: hard-disable **all** egress (telemetry, update checks, license reporting, model downloads). | +| `HEADROOM_OFFLINE` | `false` | Air-gap mode: refuse every connection Headroom itself initiates — telemetry, update checks, license reporting, model/tokenizer/binary/dataset downloads, remote Kompress, OTLP and Langfuse export, Copilot auth, the subscription pollers, the OpenAI embedders, Headroom Cloud compression and the TLS diagnostics (`headroom doctor --network` reports them as skipped). Four things still work on purpose: forwarding your own requests to the upstream you configured, operator-configured local endpoints (the Ollama embedder), loopback health probes, and paths an `is_offline()` check already disables. Refusals surface as messages, not tracebacks. See the air-gap notes in `docs/metrics-technical-guide.md`. | +| `HEADROOM_LICENSE` | unset | Licence token, read by core and every licensed extension. `HEADROOM_LICENSE_KEY` is a deprecated alias for one release and logs a warning. A licence on its own sends nothing to Headroom. | +| `HEADROOM_USAGE_REPORTING` | `false` | Set `1` to let the proxy validate the licence and post aggregate usage counts (no content) to the Headroom cloud. Off unless set; `HEADROOM_OFFLINE` still overrides it. | | `--stateless` / `HEADROOM_STATELESS` | `false` | Keep all state in memory; no filesystem writes (disables logs, memory, TOIN). | | `HEADROOM_STRIP_INTERNAL_HEADERS` | `enabled` | Strip internal `x-headroom-*` headers before forwarding upstream. | | `HEADROOM_TLS_STRICT` | strict | Set `0` to relax CA-constraint checks behind a corporate TLS-inspection proxy. | @@ -481,6 +519,26 @@ Google Cloud Code Assist / Antigravity compatibility endpoint used by Pi-style ` The proxy also accepts: - `POST /v1/v1internal:streamGenerateContent` +### Antigravity IDE + +Google's Antigravity IDE can route its model traffic through the proxy: + +```bash +headroom wrap antigravity # Start proxy + print Antigravity setup instructions +headroom mcp install --agent antigravity # Register the Headroom MCP server in Antigravity +``` + +`wrap antigravity` prints a custom OpenAI-compatible model-provider base URL +to add in Antigravity's model-provider settings; Antigravity fetches the model +list from `GET /v1/models` on its own. Native Cloud Code Assist traffic +(`google-antigravity` clients) is accepted on +[`POST /v1internal:streamGenerateContent`](#post-v1internalstreamgeneratecontent) +and routed upstream like any other Cloud Code request. + +MCP registration writes to Antigravity's global `mcp_config.json` under +`~/.gemini/` (preferring the install's current location when one already +exists) and refuses to overwrite a config file that does not parse as JSON. + ### `POST /v1/compress` Compression-only endpoint. Compresses messages and returns them without ever making a **completion request to an LLM provider** — no generation, no provider API key, no upstream chat call. Used by the TypeScript SDK, by LiteLLM's `headroom` guardrail, and by API gateways running Headroom as a sidecar. @@ -828,6 +886,40 @@ headroom wrap cursor headroom wrap grok-build ``` +Wrapping can reuse an existing proxy instead of starting a new process. Its +startup-only settings do not change when you export different values in the +calling shell. `wrap` warns when an explicit `HEADROOM_MODE`, +`HEADROOM_MIN_TOKENS`, or `HEADROOM_EXCLUDE_TOOLS` disagrees with the settings +reported by that proxy. It does not restart a shared proxy or interrupt other +clients. Restart the proxy with the desired settings, or use `--port` to start +a separate instance. Tool exclusions are compared case-insensitively using the +effective router exclusions, including built-ins and tools protected by +`HEADROOM_PROTECT_TOOL_RESULTS`. Older proxies that do not report a setting +cannot be compared for that setting. + + +`headroom wrap codex` keeps routing scoped to the Codex process it launches: it +passes the proxy URL as a `--config` override and never writes it to +`~/.codex/config.toml`. Codex runs any session started with command-line +overrides in embedded mode, so a wrapped session does not use Codex's shared +background server and Codex prints "Running without the shared background +server". If you want the shared server, write the proxy routing to Codex's own +config instead, then start `codex` directly: + +```bash +headroom init codex # this project's .codex/config.toml (trusted projects) +headroom init -g codex # ~/.codex/config.toml, for every project +``` + +That routing is persistent: it applies to every Codex session in that scope +until you remove it. `headroom unwrap codex` removes the `-g` routing; for a +project, delete the `# --- Headroom init provider ---` block from +`.codex/config.toml`. Unwrap removes only the routing: the Headroom +`SessionStart` hook that `init` adds to Codex's `hooks.json` stays, and still +starts a proxy when a Codex session begins until you delete it. On Windows, +nothing starts the proxy for this routing, so Codex cannot connect while the +proxy is down; use `headroom install apply` if you need a supervised proxy. + Cursor reads model endpoints from its settings UI, so `headroom wrap cursor` does not rewrite Cursor configuration or launch the app. After it starts the proxy, copy the printed base URL into Cursor's model settings. diff --git a/docs/content/docs/security-model.mdx b/docs/content/docs/security-model.mdx new file mode 100644 index 0000000000..dcf93eff6a --- /dev/null +++ b/docs/content/docs/security-model.mdx @@ -0,0 +1,447 @@ +--- +title: Security Model +description: What Headroom writes to disk, what leaves your host, and what the local control surfaces do and do not protect — enough detail for a security review without reading the source. +--- + +Headroom is a local MITM proxy. It sits between your coding agents and your LLM +providers, and it inspects, rewrites, caches, and re-forwards the traffic that passes +through it. That is the product, and it has direct consequences for where your data +goes and what it is protected by. + +This page documents those consequences: what is written to disk, with what permissions +and retention, what leaves the host, and what the local control surfaces do and do not +allow. It is intended to be sufficient for a security review without reading the source. + +For reporting a vulnerability, see [SECURITY.md](https://github.com/headroomlabs-ai/headroom/blob/main/SECURITY.md). + +## Trust model in one paragraph + +Headroom runs as a local process under your own user account, and it trusts that +account. It sees your full agent traffic in plaintext, and it holds whatever provider +credentials you give it. Its local HTTP surfaces are restricted to loopback, which +protects you from remote and cross-origin attackers but **not** from other processes +running as you on the same machine. Headroom is not designed for multi-tenant hosts, +and it should not be treated as an isolation boundary between users. + +## Data in flight + +The proxy terminates the connection from your agent, decompresses and parses the +request body, applies its transforms, and re-issues the request upstream using your +credentials. There is no mode in which it forwards traffic it cannot read — inspection +is what makes compression possible. + +In practice this means the proxy process sees, in plaintext: system prompts, full +conversation history, source code sent as context, tool call arguments and results, +retrieved chunks, and anything else your agent transmits. + +Transport to the provider remains TLS. See +[Governed knobs](#governed-knobs) for the one setting that relaxes certificate +validation. The trust store itself is configurable: `SSL_CERT_FILE` and +`REQUESTS_CA_BUNDLE` have **replacement** semantics — set either and only those CAs are +trusted — while `NODE_EXTRA_CA_CERTS` is **additive**, loading extra roots on top of the +system store, matching Node.js. First match wins in that order. + +### The upstream is partly client-selectable + +"Upstream" is not purely operator-configured. The OpenAI-compatible handlers accept an +`x-headroom-base-url` request header, so a client on the proxy port can choose the +destination of a single request. That is deliberate — it is how BYOK gateways (LiteLLM, +Azure, self-hosted vLLM) reach the dedicated handlers instead of generic passthrough — +and two separate guards bound it. + +**Where it may point.** A client-supplied base URL is resolved and rejected if it lands +on a private, loopback, link-local, or otherwise non-public address: the confused-deputy +case where the proxy is used to reach cloud metadata (`169.254.169.254`) or RFC1918 +hosts the caller cannot reach directly. NAT64-embedded IPv4 is unwrapped before the +check, so it cannot be smuggled through an IPv6 literal. A rejected override is +**ignored rather than fatal** — the request proceeds to the configured upstream and a +warning is logged. `HEADROOM_ALLOWED_BASE_URLS` (comma-separated hosts or URLs) +re-permits specific internal endpoints as an explicit operator choice: a bare host +permits every safe scheme and port on that host, a full URL permits only its exact +normalized origin. DNS resolution is bounded by `HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S` +(3 seconds by default). + +**What may ride along with it.** The operator's own `*_extra_headers` secrets — the two +credential-shaped maps in [the settings store](#the-settings-store) — travel only to a +host the operator designated. Designated means one of the resolved provider API targets +(`ANTHROPIC_TARGET_API_URL`, `OPENAI_TARGET_API_URL`, and the Gemini/Vertex/Bedrock/Cloud +Code equivalents), the default `api.anthropic.com` / `api.openai.com`, or a host listed +in `HEADROOM_UPSTREAM_ALLOWED_HOSTS`. Matching is exact equality on the *parsed* +hostname, never the URL string, so neither +`https://api.anthropic.com@evil.example` nor `https://api.anthropic.com.evil.example` +matches. An undesignated destination is still proxied — it simply does not receive those +headers — and the drop is warned once per host rather than once per request. + +Read the pair for what it is: it stops a client redirecting the *operator's* stored +credentials to a host of its choosing, and it stops the proxy being used as an SSRF +pivot into the local network. Neither guard authenticates the caller. They constrain +what a request can reach and what it carries, not who is allowed to make one — the same +boundary described under [Local control surfaces](#local-control-surfaces). + +## Data at rest + +Most state lives under the workspace directory — `~/.headroom` by default, relocatable +with `$HEADROOM_WORKSPACE_DIR`. **The workspace is not exhaustive**, and an inventory +that assumes it is will miss files: the memory store defaults to a *project-local* path, +and both the memory store and the Copilot token can be relocated independently. The +`Path` column below states where each item actually resolves. + +| What | Path | Contents | Retention | Encrypted | Permissions | +|---|---|---|---|---|---| +| Copilot OAuth token | `/copilot_auth.json`, or `$HEADROOM_COPILOT_AUTH_FILE` | GitHub Copilot OAuth **refresh token**, plaintext JSON | Until you delete it | No | `0600`, best-effort | +| CCR cache | `/ccr_store.db` (SQLite) | **Pre-compression originals**: tool outputs, file contents, retrieved chunks | 30 min default (`HEADROOM_CCR_TTL_SECONDS`), lazily purged | No | `0600` | +| Memory store | **Project-local by default** — see [Memory store paths](#memory-store-paths) | Extracted cross-session memories | Until deleted | No | Inherits umask | +| Memory exports | `/memories/` | Markdown exports of the above | Until deleted | No | Inherits umask | +| Install ID | `/install_id` | Random UUID identifying the install to the beacon | Until deleted | No | `0600`, best-effort | +| Savings tracker | `/proxy_savings.json` | Aggregate token/cost counters — no prompt content | Until deleted | No | Inherits umask | +| License cache | `/license_cache.json` | Cached license envelope | Per envelope | No | Inherits umask | +| Settings store | `/settings.json`, or `$HEADROOM_SETTINGS_PATH` | Dashboard-managed knob values — **including two header maps that carry credentials**, see [The settings store](#the-settings-store) | Until deleted | No | `0600`, inherited from `mkstemp` | +| Proxy log | `/logs/proxy.log` + 5 rotations | INFO-level operational log, always on; carries the admin audit stream | 10 MB × 5 backups, then rotated out | No | Inherits umask | +| Request log | Opt-in, `--log-file` / `$HEADROOM_LOG_FILE` | JSONL per-request records; **full request and response content** when `--log-messages` is on | Until deleted | No | Inherits umask | + +The config directory is `$HEADROOM_CONFIG_DIR`, else `$HEADROOM_WORKSPACE_DIR/config`, +else `~/.headroom/config`. + +**Nothing Headroom writes is encrypted at rest.** Confidentiality of everything above +rests on filesystem permissions and on the security of the account the proxy runs as. + +### The settings store + +The dashboard settings GUI persists a curated set of 59 knobs to +`/settings.json`. Two of them are marked secret, and both are credential-shaped: +`ANTHROPIC_TARGET_API_HEADERS` and `OPENAI_TARGET_API_HEADERS` are free-form JSON header +maps merged into forwarded requests — the field's own help text gives +`{"Api-Key": "..."}` as the example. Anything you put there is written to disk in +plaintext. The GUI masks them on read (`GET /settings` returns a sentinel, not the +value), but that is a display control, not an at-rest one. + +Where these two are *sent* is separately constrained: they are merged only after the +destination is known, and only for operator-designated hosts. See +[The upstream is partly client-selectable](#the-upstream-is-partly-client-selectable). +That bounds the blast radius of the values; it does not change how they are stored. + +Two further properties matter for policy: + +- **It is a second way environment knobs get set.** `settings_store.apply_to_environ` runs + at CLI startup and again when the app is constructed, using `os.environ.setdefault`. The + precedence is `shell export > settings.json > code default`, so a stored value silently + becomes the effective configuration on any host where the operator assumed the + environment was the only input. +- **The file is `0600` by construction, not by assertion.** `save()` writes atomically via + `tempfile.mkstemp` + `os.replace`, and inherits mkstemp's `0600`. There is no explicit + `chmod`, so treat the permission as a property of the current implementation rather than + a guarantee. + +`HEADROOM_TLS_STRICT`, `HEADROOM_BEACON`, `HEADROOM_CCR_BACKEND`, and `HEADROOM_OFFLINE` +are **not** in the registry — none of them can be set from this file or the routes that +write it. + +### Logs + +Two log paths, with very different sensitivity: + +- **`/logs/proxy.log` — always on.** A `RotatingFileHandler` (10 MB, 5 backups) + is attached to the `headroom` logger when the app is created; there is no flag to + suppress it, and it is written before configuration is otherwise applied. It carries + INFO-level operational records, and — because the audit logger is a child of `headroom` + and writes no file of its own — the structured admin audit stream lands here too. Audit + events record the action, method, path, source IP, and status code of `/admin/*`, + `/cache/clear`, and `/stats/reset` calls. + + Note the permission asymmetry: unlike `copilot_auth.json`, `ccr_store.db`, and + `settings.json`, this file gets no explicit mode. Under a common `0002` umask it lands + at `0664` — readable by every account on the host. +- **The request log — opt-in, and content-bearing when you ask for it.** `--log-file` + (`$HEADROOM_LOG_FILE`) writes one JSON object per request. By default those objects hold + metadata only. `--log-messages` (`$HEADROOM_LOG_MESSAGES`) adds full request and response + message content to the same file *and* serves it on the live feed endpoint. Its own help + text warns that it may log sensitive data; on this page's terms, it writes your prompts + and completions to disk unencrypted, for as long as you keep the file. + +Both `HEADROOM_LOG_FILE` and `HEADROOM_LOG_MESSAGES` are in the settings registry, which +means full message logging can be switched on — and its destination chosen — from the +settings surface described under +[Local control surfaces](#local-control-surfaces). `--stateless` suppresses the request +log entirely (`log_file` is forced to `None`); it does not suppress `proxy.log`. + +### Memory store paths + +Memory is the one store that does **not** default into the global workspace. With +`--memory` enabled and `--memory-db-path` unset, the proxy creates +`{cwd}/.headroom/memory.db` — relative to the working directory the proxy was launched +from, so a fleet running agents in many repos produces many databases. + +Partitioning is then governed by `--memory-storage`, which defaults to `project`: + +| Mode | Where memories land | +|---|---| +| `project` (default) | One DB per resolved workspace, under `/memories/projects/-/memory.db` | +| `user` | One DB per resolved identity — see the note on `x-headroom-user-id` below | +| `global` | A single shared DB — the `--memory-db-path` file itself | + +`--memory-db-path` (env `HEADROOM_MEMORY_DB_PATH`) sets the `global`-mode file and seeds +the storage root for `project` mode. Scoping can also be steered per-request with the +`x-headroom-project-id` and `x-headroom-cwd` headers. + +`x-headroom-user-id` is treated differently, and the distinction matters for anyone +reasoning about who can read whose memories. It is a partition **hint, not an +authenticated identity**, and `resolve_memory_identity` honors it only for loopback +callers — the single-user local model. Any other caller is bound to a hash of +`HEADROOM_PROXY_TOKEN`, or failing that the server's OS user, so a network client cannot +select another user's partition by asserting a header. The check fails closed: a request +whose peer metadata is missing is not treated as loopback, so unusual ASGI transports +cannot opt into header trust. Multi-tenant deployments replace the default resolver +through `set_identity_resolver`; the OSS proxy ships only the single-user default. + +For an inventory or a data-deletion procedure, enumerate project-local +`.headroom/` directories as well as the workspace — memories are the store most likely +to sit outside it. + +### The CCR cache deserves specific attention + +CCR ("Compress-Cache-Retrieve") makes compression reversible by keeping the original +content so the model can retrieve it on demand. That original is written to disk. + +By default — `HEADROOM_CCR_BACKEND` unset or `sqlite` — entries land in +`~/.headroom/ccr_store.db` as one JSON row per entry, with the original in an +`original_content` field, in plaintext. The store is restart-safe by design, because the +30-minute TTL assumes it can be shared across worker processes. + +Concretely: if a tool result contained a credential, an internal hostname, or a customer +record, that value is on disk in a readable SQLite database for at least the TTL window. + +Two behaviors worth knowing: + +- **Expiry is lazy, not scheduled.** Expired rows are removed by a sweep that runs on + lookup, not by a background timer. On an idle proxy, content past its TTL remains on + disk until the next access. The TTL bounds *retrievability*, not *residency*. +- **The backend can fall back silently.** If the SQLite backend fails to initialize, + Headroom logs a warning and continues with an in-memory store. Retrieval still works; + it simply stops surviving restarts. + +To avoid disk persistence entirely: + +```bash +HEADROOM_CCR_BACKEND=memory headroom proxy --port 8787 +``` + +This trades restart-safety and multi-worker sharing for keeping originals in process +memory only. + +## Local control surfaces + +The proxy exposes HTTP routes beyond the provider-shaped ones. The sensitive ones — +`/admin/*`, `/debug/*`, `/cache/clear`, `/stats/reset`, `/settings*`, `/transformations/feed`, +`/v1/feedback*`, `/v1/telemetry*`, `/v1/toin/*`, and the content-bearing `/v1/retrieve*` +family — are guarded by `require_loopback`, which enforces two independent conditions: + +1. The client address must be a loopback IP. +2. The inbound `Host:` header must also name loopback. + +The second gate is what defeats DNS rebinding, where a remote page resolves an +attacker-controlled hostname to `127.0.0.1` so the browser connects locally while the +page origin stays remote. The IP check alone passes in that scenario; the `Host:` check +does not. Guarded routes return **404**, not 403, so they are indistinguishable from +absent routes to a scanner. + +A third gate, `require_same_origin`, sits on the mutating routes (`/admin/runtime-env`, +`/cache/clear`, `/stats/reset`, `/settings`, `/settings/apply`). It defends a different +attack: a plain CSRF, where a remote page's script posts a "simple" request straight at +`http://127.0.0.1:`. Both loopback gates pass in that case — the browser really is +connecting to loopback and really does send a loopback `Host:` — but the `Origin:` header +still names the attacker's page, and that is what this gate rejects. Requests with no +`Origin` at all (CLI tools, curl) pass, which is the intended behaviour for a +locally-driven surface. + +### The two CIDR allowlists + +Two environment variables can extend this beyond loopback. Both default to empty, so the +default posture is the one described above — but an operator who sets either has changed +the boundary and should say so in their own threat model. + +| Variable | Effect | +|---|---| +| `HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS` | Peers in these CIDRs reach `/settings`, `/settings/schema`, `/dashboard/settings`, and the settings *writes* **without being on loopback** — and additionally see the fields `/stats` and `/stats-lifetime` otherwise withhold from network callers (per-request ids, providers, models, errors, project names, backend config). Added so the settings UI works behind a reverse proxy. | +| `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` | Peers in these CIDRs are trusted to supply `X-Forwarded-*` headers, which is what makes the client IP the *forwarded* one rather than the gateway's. | + +Both parse strictly: a malformed CIDR raises at startup rather than silently emptying the +allowlist. Note the interaction — trusting a gateway for `X-Forwarded-For` means the +dashboard CIDR check is applied to a client-supplied header, so the two must be configured +together and pointed at infrastructure you control. + +### What `POST /admin/runtime-env` can change + +`headroom wrap` hot-syncs settings to a running proxy through this route. It does not +accept arbitrary environment variables — writes are restricted to a hardcoded allowlist +(`RUNTIME_ENV_KNOBS`), currently: + +| Knob | Purpose | +|---|---| +| `HEADROOM_OUTPUT_SHAPER` | Master switch for output-token shaping | +| `HEADROOM_VERBOSITY_LEVEL` | Verbosity steering level | +| `HEADROOM_VERBOSITY_AUTOTUNE` | AIMD verbosity controller state | +| `HEADROOM_OUTPUT_HOLDOUT` | Output-shaping holdout fraction | +| `HEADROOM_INTERCEPT_READ_MIN_CHARS` | ast-grep read-interception threshold | +| `HEADROOM_VERIFY_TRUNCATION_ON_DISK` | Verify ast-grep Read truncation against disk when no client banner is found. Always returns `UNKNOWN` on Windows, where `dir_fd` / `O_NOFOLLOW` support is unavailable. | +| `HEADROOM_VERIFY_TRUNCATION_MAX_BYTES` | Max on-disk file size (bytes) the disk-verify fallback will read | + +Keys outside this list are ignored, and `/admin/upstream` is read-only — there is no +write route on the admin surface, specifically so a caller cannot redirect +credential-bearing traffic through it. + +### What `POST /settings` can change + +The settings routes are the wider surface, and they should be read as part of the same +threat model as `/admin/*` rather than as a separate UI concern. `POST /settings` +validates against the 59-field registry described under +[The settings store](#the-settings-store) and writes the result to disk; +`POST /settings/apply` does the same and then **restarts the proxy** so the values take +effect (self-restarting on service deployments, returning the host command under Docker). + +That combination has consequences worth stating plainly: + +- **The upstream URL *can* be changed through this surface.** `ANTHROPIC_TARGET_API_URL` + and `OPENAI_TARGET_API_URL` are in the registry. `/admin/upstream` remains read-only, + but a caller who reaches `/settings` can still repoint credential-bearing traffic at a + destination of their choosing and restart the proxy to apply it. If you are relying on + the admin surface being read-only, this is the path that gets around it. +- **Full message logging can be switched on.** `HEADROOM_LOG_MESSAGES` and + `HEADROOM_LOG_FILE` are both in the registry, so the same caller can start writing your + prompts and completions to a file path they choose. +- **Credential-shaped values can be written.** The two `*_TARGET_API_HEADERS` maps are + stored here in plaintext. + +What is *not* reachable, verified against both the runtime-env allowlist and the settings +registry: `HEADROOM_TLS_STRICT`, `HEADROOM_BEACON`, `HEADROOM_CCR_BACKEND`, and +`HEADROOM_OFFLINE`. TLS verification in particular cannot be relaxed over HTTP; it is read +from the process environment at startup only. + +All of these calls are audited. `/admin/*`, `/cache/clear`, and `/stats/reset` emit a +structured event carrying action, method, path, source IP, and status; `/admin/runtime-env`, +`POST /settings`, and `POST /settings/apply` add their own with the *changed keys* — keys +only, deliberately, so a secret's value never reaches the log. The audit stream is a +logger, not a file of its own, so in a default install it lands in +`/logs/proxy.log`. + +### Residual risk + +The loopback and same-origin gates are an effective boundary against remote and +browser-based attackers. They are not a boundary against local ones. Any process that can +bind a loopback socket as your user can flip the seven runtime knobs, write the 59 +settings and restart the proxy to apply them, read `/v1/retrieve*`, and reach the debug +routes. There is no token, no mTLS, and no per-caller authorization — the guards +distinguish *where a request came from*, not *who sent it*. + +For single-user developer machines — the intended deployment — this is proportionate. +For shared build boxes, multi-user jump hosts, or anywhere untrusted local code runs, +it is not, and Headroom should not be deployed there without additional isolation. + +## Network egress + +Beyond your configured LLM providers, Headroom may contact: + +| Destination | Purpose | Default | Disable / pre-provision | +|---|---|---|---| +| `headroom-beacon.headroom-beacon.workers.dev` | Anonymous session summaries to Headroom Labs | **On** — see [The beacon](#the-beacon-is-on-by-default) | `HEADROOM_BEACON=off`, `DO_NOT_TRACK=1`, or `HEADROOM_OFFLINE` | +| `pypi.org` | Daily version check for update notices | On | `HEADROOM_UPDATE_CHECK=off`; also skipped in `--stateless`, in CI, and from source checkouts | +| `huggingface.co` | Downloads models for the optional ML compression paths | On first use of those features | Pre-provision the model cache, or leave the features disabled | +| `app.headroomlabs.ai` | License validation and usage reporting | Only with a license key configured | Omit the license key | + +### The beacon is on by default + +This is the one egress path that is enabled without any action from you, so it warrants +its own treatment. It is governed by `HEADROOM_BEACON` and is **opt-out** +(`BEACON_DEFAULT_ON = True`). Session summaries are POSTed to +`https://headroom-beacon.headroom-beacon.workers.dev/v1/logs`, currently a Cloudflare +`workers.dev` address. + +**Payload class: counters and identifiers, no content.** Each report is a cumulative +snapshot — not a delta — carrying: + +- **Session**: a random id, a sequence number, duration, turn count, and whether this is + the final report for the session. +- **Tokens**: original, attempted, input, output, saved, tool-saved, cache read/write, and + uncached counts, plus derived percentages (saved, eligible, yield, all-layers variants, + cache-read, latency overhead). +- **Compression**: transform and skip tallies keyed by transform slug, a per-strategy + breakdown (`strategy`, event count, tokens in, tokens out), aggregate and per-turn + latency/overhead in milliseconds, passthrough turns, and response-cache hits. +- **Context**: request sources (`proxy` / `mcp`), the sorted provider and model names seen, + a failure count, and those failures split by HTTP status code. + +Alongside each report go resource attributes: a random per-install UUID, Headroom version, +OS type, CPU architecture, and — when known — install mode and detected stack. + +The keys in the tally dictionaries are internal slugs, not free text: transform names are +truncated at the first colon before being counted, and sources are one of two literals. +Prompts, completions, file contents, tool results, and file paths are **not** in the +payload. The install id is a random UUID rather than a machine fingerprint, and resets +if you delete `/install_id`. + +Three controls switch it off, checked in this order: + +```bash +HEADROOM_OFFLINE=1 # offline mode — disables all egress, not just the beacon +DO_NOT_TRACK=1 # the cross-tool convention, honoured +HEADROOM_BEACON=off # also false / 0 / no / disable / disabled +``` + +The destination can also be redirected with `HEADROOM_TELEMETRY_ENDPOINT`, which is +useful if you would rather terminate the reports at a receiver you control than block +them outright. + +One asymmetry is worth knowing for policy purposes: `HEADROOM_TELEMETRY` is fail-*closed* +(an unrecognised value leaves it off), whereas the beacon is fail-*open* while the +default stands — an unrecognised `HEADROOM_BEACON` value uploads. If you are disabling it +by configuration management, assert one of the exact off-values above rather than +assuming a typo fails safe. + +### Local telemetry and OpenTelemetry are separate + +`HEADROOM_TELEMETRY` is a **different switch**, off by default and opt-in. It aggregates +stats locally for the in-process collector and the `/stats` and `/v1/telemetry` +endpoints, and sends nothing off-host. Enabling it does not authorise beacon upload, and +disabling it does not suppress the beacon — the two are deliberately independent. + +Operational metrics are separate again: OpenTelemetry export +(`HEADROOM_OTEL_METRICS_*`) is opt-in and targets a collector you specify. + +For a controlled fleet, set `HEADROOM_BEACON=off` (or `DO_NOT_TRACK=1`) via configuration +management, pin the package version, set `HEADROOM_UPDATE_CHECK=off`, and pre-provision +model assets rather than allowing runtime fetches. `HEADROOM_OFFLINE=1` covers all four +at once where fully-offline operation is acceptable. + +## Governed knobs + +Two further environment variables are worth an explicit policy decision. +(`HEADROOM_BEACON` is the third, covered under [The beacon](#the-beacon-is-on-by-default).) + +A note on where "the environment" comes from: `settings.json` is applied to the process +environment at startup with `setdefault`, so for any knob in the settings registry the +effective value may come from that file rather than from your shell. None of the three +knobs in this section are in that registry, but a fleet policy asserted purely by +inspecting exported environment variables is incomplete for the ones that are — see +[The settings store](#the-settings-store). + +**`HEADROOM_TLS_STRICT=0`** relaxes certificate validation so Headroom can operate +behind a corporate TLS-inspecting proxy. It is narrowly scoped — it clears only the +RFC 5280 strict flag and does not disable verification wholesale — but it is still a +loosening of transport security and should be set deliberately rather than habitually. +See the README for details on scope and platform behavior. + +**`HEADROOM_UPDATE_CHECK=off`** disables the daily PyPI version check. Recommended for +managed fleets, where upgrades should be driven by your own release process. + +Neither is reachable through the admin endpoint or the settings routes; both are read +from the process environment only. + +## What `wrap` changes on your machine + +`headroom wrap` makes persistent modifications to your development environment: it +injects configuration into agent config files, may edit `CLAUDE.md` / `AGENTS.md`, and +can install MCP servers. `headroom unwrap` reverses the wrapping. + +A full teardown currently spans several commands and is tracked in +[#748](https://github.com/headroomlabs-ai/headroom/issues/748), which also proposes a +consolidated `headroom uninstall`. Until that lands, treat wrapping as a change to +inventory on managed machines. diff --git a/docs/content/docs/teams.mdx b/docs/content/docs/teams.mdx new file mode 100644 index 0000000000..62189fefe7 --- /dev/null +++ b/docs/content/docs/teams.mdx @@ -0,0 +1,27 @@ +--- +title: Headroom for Teams +description: Headroom Enterprise for engineering teams — more savings, automatic model routing, attack removal, SSO and an org-wide savings dashboard. 3 months free with a 12-month plan. +--- + +Headroom OSS is built for one developer on one laptop. Headroom Enterprise is built +for the whole team. + +| | Open source | Enterprise | +|---|---|---| +| Savings | Compression, plus tool search on Anthropic requests (on by default) | Also: tool search for every model, harness tuning, extra compressors | +| Model routing | Rule-based and opt-in: turn it on with `HEADROOM_MODEL_ROUTER_ENABLED=1` and write the rules in [`HEADROOM_MODEL_ROUTES`](/docs/configuration#cost-aware-model-routing) | Automatic: a cheaper model per request when quality allows, with a shadow mode that shows the savings before you turn it on | +| Security | — | Detect and strip prompt injection and risky tool calls | +| Rollout | Per laptop | Managed or in your VPC, SSO, central config, org-wide savings | +| Support | GitHub, Discord | Dedicated engineers, onboarding, priority response | + +## 3 months free + +Companies that sign a 12-month Enterprise plan get their first 3 months free. One +perk per company. + +**[Claim 3 months free →](https://headroom-perks.vercel.app/?source=github&utm_source=docs&utm_medium=teams-page)** + +Prefer to talk first? Email [hello@headroomlabs.ai](mailto:hello@headroomlabs.ai) +with your stack and rough monthly LLM spend. + +Everything in the open-source repo stays open source under Apache 2.0. diff --git a/docs/content/docs/troubleshooting.mdx b/docs/content/docs/troubleshooting.mdx index 0372d41ffa..07620bd50f 100644 --- a/docs/content/docs/troubleshooting.mdx +++ b/docs/content/docs/troubleshooting.mdx @@ -215,11 +215,48 @@ See [issue #746](https://github.com/headroomlabs-ai/headroom/issues/746) for the ## Remote Control unavailable through custom ANTHROPIC_BASE_URL -**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent. +**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent. Invoking `/remote-control` reports `Remote Control is only available when using Claude via api.anthropic.com`. -**Cause**: This is a Claude-side gate. Headroom only receives normal API traffic and can still compress it, but Claude evaluates Remote Control availability before proxy traffic reaches the server. +**Cause**: A client-side gate, evaluated before any traffic reaches Headroom. Claude Code v2.1.196+ parses `ANTHROPIC_BASE_URL` and compares the host against `api.anthropic.com` exactly, so a loopback address fails it. The check is satisfied by any one of: -**Fix**: Use Headroom for normal proxied API sessions, and launch Claude directly (without `ANTHROPIC_BASE_URL`) when you need Claude Remote Control. +- `ANTHROPIC_BASE_URL` unset, or naming `api.anthropic.com` as its host, or +- `ANTHROPIC_UNIX_SOCKET` pointing at a Unix domain socket — but see [below](#why-the-unix-socket-route-does-not-help): it passes this check and fails a different one. + +Note that Remote Control does **not** honor `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL`, the flag that re-enables the 1M window and on-demand tool loading — it reads the base URL directly. Headroom cannot re-enable Remote Control from the server side the way `--1m` restores the `context-1m` beta header, because the eligibility decision and the bridge connection both happen in the client. + +**Fix**: Launch Claude directly, without `ANTHROPIC_BASE_URL`, for sessions that need Remote Control, and use Headroom for the rest. + +### Why the Unix socket route does not help + +`ANTHROPIC_UNIX_SOCKET` satisfies the host check above, so it looks like a way to keep a first-party base URL while routing through Headroom. It does not work, and the reason is worth recording so nobody re-derives it. + +That variable is reserved for `claude ssh`, where the process on the other end of the socket supplies credentials. Claude Code 2.1.198 carries the diagnostic verbatim: + +``` +ANTHROPIC_UNIX_SOCKET is set (claude ssh remote), and the local proxy is API-key-authed. +``` + +Setting it therefore makes Claude Code classify the session as API-key auth. Remote Control separately requires *claude.ai subscription* auth — the binary's eligibility messages include `Remote Control requires claude.ai subscription auth.` — so the one variable opens the host gate and closes the subscription gate at the same time. + +Confirmed on Linux against Claude Code 2.1.198 with `headroom proxy --uds` and no API key set. The session starts with: + +``` +⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set + and takes precedence over your claude.ai login +``` + +sends `Not logged in · Please run /login`, and after a successful subscription login still fails inside the SDK, because nothing ever attached a credential: + +``` +API invalid_api_key: Could not resolve authentication method. Expected one of apiKey, +authToken, credentials, config, or profile to be set. +``` + +No proxy-side change can repair this: the eligibility decision happens in the client before any request is made. + +**What remains**: a transport that leaves `ANTHROPIC_BASE_URL` unset and does not set `ANTHROPIC_UNIX_SOCKET` — an `HTTPS_PROXY` CONNECT/MITM front end with a locally-generated CA trusted through `NODE_EXTRA_CA_CERTS`. That keeps the host genuinely `api.anthropic.com` and leaves subscription auth untouched. It is tracked on [issue #1779](https://github.com/headroomlabs-ai/headroom/issues/1779); `headroom proxy --uds` is still useful as a transport, just not for this. + +The upstream request to allow loopback proxies is tracked at [anthropics/claude-code#76653](https://github.com/anthropics/claude-code/issues/76653). `ENABLE_TOOL_SEARCH` is unaffected and can stay enabled for context-window savings while routing through Headroom. diff --git a/docs/content/docs/vscode-copilot.mdx b/docs/content/docs/vscode-copilot.mdx index 41a8c32997..28ca276e10 100644 --- a/docs/content/docs/vscode-copilot.mdx +++ b/docs/content/docs/vscode-copilot.mdx @@ -159,6 +159,9 @@ both `copilot-auth login` and `wrap vscode`. does not add or rename models. - If connection is refused, keep the wrapper running and check loopback/remote port reachability. +- If Headroom records no requests while Copilot keeps working, the window is + probably using a VS Code profile with its own settings. `wrap vscode` warns + about each such profile and prints the `--settings-file` path to configure it. - Use `--port 8788` when the default port is occupied; settings update safely. - If Headroom refuses settings, repair the reported JSONC/marker conflict or use `--no-configure` and apply the printed settings manually. diff --git a/docs/lib/layout.shared.ts b/docs/lib/layout.shared.ts index aad9c0be4d..994a1c22ac 100644 --- a/docs/lib/layout.shared.ts +++ b/docs/lib/layout.shared.ts @@ -6,5 +6,12 @@ export function baseOptions(): BaseLayoutProps { title: 'Headroom', }, githubUrl: 'https://github.com/headroomlabs-ai/headroom', + links: [ + { + text: 'Headroom for Teams', + url: '/docs/teams', + active: 'nested-url', + }, + ], }; } diff --git a/docs/metrics-technical-guide.md b/docs/metrics-technical-guide.md index b5ff7e1520..eefb53c698 100644 --- a/docs/metrics-technical-guide.md +++ b/docs/metrics-technical-guide.md @@ -227,7 +227,7 @@ These rows *explain* the headline total — they are never added to it. 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 `. Loopback is always exempt. +5. **`/metrics` needs auth if you set a proxy token.** With `HEADROOM_PROXY_TOKEN` set, any non-loopback scraper must send `Authorization: Bearer ` (or `X-Headroom-Proxy-Token: `). Loopback is always exempt. The proxy removes the token from every request before forwarding it, so it never reaches a model provider. --- @@ -268,6 +268,17 @@ 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. +**Air-gapped deployments:** `HEADROOM_OFFLINE=1` refuses every connection Headroom itself decides to make to a destination Headroom itself chose. That is the whole list, not a sample: the anonymous usage beacon (which is **on by default**), the update check, the license/usage reporter, **OTLP metric and Langfuse trace export**, HuggingFace / Kompress / fastembed model and tokenizer downloads (Python and Rust), the remote Kompress endpoint, the `headroom install` release-binary and codebase-memory-mcp downloads, the BFCL eval-dataset fetch and the provider SDK clients the eval harness drives, GitHub Copilot device-flow auth and token exchange, the Anthropic / Codex / Copilot subscription pollers, the OpenAI embedders, the Headroom Cloud compression modes in the ASGI and LiteLLM integrations, and the TLS diagnostics (`headroom doctor --network` endpoint checks and the certificate-chain re-probe after an upstream TLS failure). + +Four things are deliberately still allowed, and they are the complete set of exceptions: + +1. **Your traffic through the proxy.** Requests you send *through* Headroom are still forwarded to the upstream you configured. That is your traffic, not Headroom's, and an air-gapped deployment points it at an on-prem endpoint — refusing it would mean refusing to be a proxy. +2. **Operator-configured local endpoints.** Today that is exactly one thing: the Ollama embedder, whose address comes entirely from your configuration and defaults to `http://localhost:11434`. No hard-coded internet host is permitted under this exception. +3. **Loopback.** Health and readiness probes against your own proxy on `127.0.0.1` (`headroom doctor`, the installers, the MCP sidecar). +4. **Paths an `is_offline()` check already makes unreachable**, where no connection is ever built in the first place. + +Those four are enumerated with written reasons in `_EGRESS_ALLOWLIST` in `tests/test_offline_egress_chokepoint.py`, which fails the build if a new egress path appears that is neither guarded nor one of them. There is no category for "known violation": a path that can dial the internet with the flag set is a bug. A refusal reaches you as a message — a Click error on the CLI, a named "model unavailable" from a model loader, a logged line from a background poller — never as a traceback and never as a silent degradation. Enforcing the same policy at the network layer as well is still good practice; it is no longer the only thing standing between you and Headroom-initiated egress. + +OTLP export is refused loudly: with `HEADROOM_OFFLINE=1` set, `HEADROOM_OTEL_METRICS_ENABLED=1` plus the default `otlp_http` exporter makes the proxy exit 78 at startup with an explanation, rather than quietly dropping metrics. There is no exemption for a collector that looks local — an in-cluster address is not reliably distinguishable from an internet one. For metrics under an air-gap, either scrape `localhost:8787/metrics` or set `HEADROOM_OTEL_METRICS_EXPORTER=console`, both of which stay on-box. `HEADROOM_KOMPRESS_ENDPOINT` and `HEADROOM_LANGFUSE_ENABLED` are refused the same way, for the same reason. --- diff --git a/docs/package-lock.json b/docs/package-lock.json index 19cd298212..7905229ca8 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -13,28 +13,28 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "dotted-map": "^3.1.0", - "fumadocs-core": "16.15.12", - "fumadocs-mdx": "15.4.3", + "fumadocs-core": "16.15.15", + "fumadocs-mdx": "15.4.5", "fumadocs-twoslash": "^4.0.1", "fumadocs-typescript": "^5.4.1", - "fumadocs-ui": "16.15.12", - "lucide-react": "^1.7.0", - "next": "16.3.5", + "fumadocs-ui": "16.15.15", + "lucide-react": "^1.48.0", + "next": "^16.3.8", "react": "^19.3.0", "react-dom": "^19.3.0", "recharts": "^3.10.1", "tailwind-merge": "^3.7.0" }, "devDependencies": { - "@ai-sdk/openai": "^4.0.71", - "@anthropic-ai/sdk": "^0.127.0", + "@ai-sdk/openai": "^4.0.79", + "@anthropic-ai/sdk": "^0.129.0", "@tailwindcss/postcss": "^4.3.3", "@types/mdx": "^2.0.14", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "ai": "7.0.107", - "openai": "^7.20.0", + "ai": "7.0.119", + "openai": "^7.23.0", "postcss": "^8.5.28", "tailwindcss": "^4.2.2", "typescript": "^7.0.2" @@ -44,14 +44,14 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "4.0.87", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.87.tgz", - "integrity": "sha512-6wJmX/D5OO7tvDJbtFdXdBuRt8AkCnW+FLK+W35UHe/tHgolAa4hclXtJcMSYnlTDy/t5DNRgdesVaoMXtwzow==", + "version": "4.0.97", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.97.tgz", + "integrity": "sha512-YFS9McgEDHXPwXOWlu1JCylkFyDsgPNLwH9jXgMxdiaur3k6SaIUPKCRSciQQ5LbEZOSb4JAaPYYO7B5Cvq7ow==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.17", - "@ai-sdk/provider-utils": "5.0.45", + "@ai-sdk/provider": "4.0.19", + "@ai-sdk/provider-utils": "5.0.50", "@vercel/oidc": "3.2.0" }, "engines": { @@ -62,14 +62,14 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "4.0.71", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.71.tgz", - "integrity": "sha512-w8FuHr1bD/bP/8SIgcaJKUyePE4ke9s7+Ew2ed/9i5mDoFXEfyVUyFX8R0wQ3YfcdMKmTs8ct/f6XJsXsnPVsQ==", + "version": "4.0.79", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.79.tgz", + "integrity": "sha512-uqRlbxy04WhRuLd5dJUYFbpeKfN5Hn/d/K/fmYswbJq6RhuWdWCRoEmuLpFTpdqku9OSQGMerXMVZQi5Ou8/7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.17", - "@ai-sdk/provider-utils": "5.0.45" + "@ai-sdk/provider": "4.0.19", + "@ai-sdk/provider-utils": "5.0.50" }, "engines": { "node": ">=22" @@ -79,9 +79,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.17.tgz", - "integrity": "sha512-VYMBxIQdcHqbIf1j+YZlI9Ati6LZ4wJe0GGd4z4a5H/KxTggjeOiyaVYTnfF7LHZK5jMQ+rofmzz4QPqf++NUw==", + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.19.tgz", + "integrity": "sha512-ryVZSSfmpEsr4bM8rbt9cSBWal+KGYRrm57C+k/hiPQHMiMahaqg2bPF0MpxSUWY8mGpt7s/ajM6PAqNTMKqPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -92,13 +92,13 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "5.0.45", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.45.tgz", - "integrity": "sha512-gLuaCups8OCIRz3eO6WWz5op7VLYNNtFzyH0sWUr0FE9v7cTxR0qxDpQdrKnGcql+9WWttobjnZsJVsuEbsPQQ==", + "version": "5.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.50.tgz", + "integrity": "sha512-qg2LutM5RlUT/tuuFf+VkhFGNZ2sEJHL0oDIrFN/sMoVbDBANglqtGCJUOnBqGMoFBH8IfAXMrFZvbFWx4dMOg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.17", + "@ai-sdk/provider": "4.0.19", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", @@ -123,9 +123,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.127.0.tgz", - "integrity": "sha512-OKiVbG9LHCxjn4fBJZJFy/Bz3hqE1RYpwaeSEDNheQnvz0XNKmGT/Lhkz2Sf1a27Ldt3vVv+BkU81I0U/8EpoQ==", + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", "dev": true, "license": "MIT", "dependencies": { @@ -724,9 +724,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -742,13 +742,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -764,20 +764,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -787,9 +787,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -803,9 +803,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -819,9 +819,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -838,9 +838,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -857,9 +857,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -876,9 +876,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -895,9 +895,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -914,9 +914,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -933,9 +933,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -952,9 +952,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -971,9 +971,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -992,13 +992,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -1017,13 +1017,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -1042,13 +1042,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -1067,13 +1067,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -1092,13 +1092,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -1117,13 +1117,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -1142,13 +1142,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -1167,13 +1167,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1187,16 +1187,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1206,9 +1206,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1225,9 +1225,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1244,9 +1244,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1347,15 +1347,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1369,9 +1369,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1385,9 +1385,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -1404,9 +1404,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -1423,9 +1423,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -1442,9 +1442,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -1461,9 +1461,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1477,9 +1477,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -2868,9 +2868,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -3444,15 +3444,15 @@ } }, "node_modules/ai": { - "version": "7.0.107", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.107.tgz", - "integrity": "sha512-PVYQ3W9kR8oYiGQxbg4aFIlhQzKskZm8UV+4E0zB2uKOzFr8ju3bXQncOOZtLsOgJY8vdpgyu3cXtomsfcdQYg==", + "version": "7.0.119", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.119.tgz", + "integrity": "sha512-KlHbkKMmdb2g0DXdkhBXzSvLSkdoAg0MdShhTTOyuoHKJetEUlgjn2juZ05lHY97EqCwBuSK8dgiEeB0ty5xZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "4.0.87", - "@ai-sdk/provider": "4.0.17", - "@ai-sdk/provider-utils": "5.0.45" + "@ai-sdk/gateway": "4.0.97", + "@ai-sdk/provider": "4.0.19", + "@ai-sdk/provider-utils": "5.0.50" }, "engines": { "node": ">=22" @@ -4067,9 +4067,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.15.12", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.15.12.tgz", - "integrity": "sha512-niwRJhxjCQ4eKSLvt3YRxxsoXDbr4R091p0t6X7LytnDrpFUNnzKGTeEekiwQ7Qvr08kZLkb9x3fbGirhY/mgQ==", + "version": "16.15.15", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.15.15.tgz", + "integrity": "sha512-kEQLKRlpRl16MfMrlAYiEpk3FPadn4725R3QHYVnF3tJhflRXvznWaJ5pP33F09d6qnOss5onEIgMpAO6rOrpA==", "license": "MIT", "dependencies": { "@fumari/image-size": "^0.1.1", @@ -4174,9 +4174,9 @@ } }, "node_modules/fumadocs-mdx": { - "version": "15.4.3", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.4.3.tgz", - "integrity": "sha512-CWhSg6BsJlEtSLcuCKyHIN1NT+n5G7llmsnM2v8RfGixzrinmLQdQ2dAe8Jt7svp1yBCAFObvtC6UdJ0oqn55A==", + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.4.5.tgz", + "integrity": "sha512-r8gzaqleN+YXgPIbkYSZ05zr89dsr67/64Ar/00Uu7dRWTIHzbgmN1nC0lB8RFNeWnrL41bOTJilhLM4PAsrbw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", @@ -4329,9 +4329,9 @@ } }, "node_modules/fumadocs-ui": { - "version": "16.15.12", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.15.12.tgz", - "integrity": "sha512-e6VAlZm95CvgOgFDcnWM5qe+6yY/cvPHkyp7DxJika4DiBuaInqrJlDrl+BWA/9mv5p0HW+ZKn9x1L1OrnkW2g==", + "version": "16.15.15", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.15.15.tgz", + "integrity": "sha512-wE9gTz+JYoz9wwxaNCfj7jqN7c7oAY7lCyuFootpY8jmGiyZHfy6db2cxlRwjm+qEaLdsV/KOsAjbuuT/n8elg==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", @@ -4360,7 +4360,7 @@ "peerDependencies": { "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.15.12", + "fumadocs-core": "16.15.15", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -4966,9 +4966,9 @@ } }, "node_modules/lucide-react": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", - "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5968,12 +5968,12 @@ } }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5987,14 +5987,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { @@ -6058,9 +6058,9 @@ } }, "node_modules/openai": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-7.20.0.tgz", - "integrity": "sha512-5x5ru4cKEvEqHqYZbdIbT57OE7KqmP9YYxZiUKuKng016grOHXqom1vmu34MTPPW30q+lcP9RhxAbaRse+Xlcw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.23.0.tgz", + "integrity": "sha512-0ecOXnFSMNWZq6cUBcTV6Lf93y+fm8BH/+QzFvpoP1UGNOE5pnytRa6HAPGCw+3IS9SIAW7rc1M8yyzN1PiBAQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6594,9 +6594,9 @@ } }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -6611,31 +6611,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -6713,9 +6713,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/docs/package.json b/docs/package.json index e62dfbcdc0..da7eb5245f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,28 +17,28 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "dotted-map": "^3.1.0", - "fumadocs-core": "16.15.12", - "fumadocs-mdx": "15.4.3", + "fumadocs-core": "16.15.15", + "fumadocs-mdx": "15.4.5", "fumadocs-twoslash": "^4.0.1", "fumadocs-typescript": "^5.4.1", - "fumadocs-ui": "16.15.12", - "lucide-react": "^1.7.0", - "next": "16.3.5", + "fumadocs-ui": "16.15.15", + "lucide-react": "^1.48.0", + "next": "^16.3.8", "react": "^19.3.0", "react-dom": "^19.3.0", "recharts": "^3.10.1", "tailwind-merge": "^3.7.0" }, "devDependencies": { - "@ai-sdk/openai": "^4.0.71", - "@anthropic-ai/sdk": "^0.127.0", + "@ai-sdk/openai": "^4.0.79", + "@anthropic-ai/sdk": "^0.129.0", "@tailwindcss/postcss": "^4.3.3", "@types/mdx": "^2.0.14", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "ai": "7.0.107", - "openai": "^7.20.0", + "ai": "7.0.119", + "openai": "^7.23.0", "postcss": "^8.5.28", "tailwindcss": "^4.2.2", "typescript": "^7.0.2" diff --git a/e2e/docker-bind-security.sh b/e2e/docker-bind-security.sh index f9e17a8799..a3af571d7e 100644 --- a/e2e/docker-bind-security.sh +++ b/e2e/docker-bind-security.sh @@ -116,9 +116,18 @@ docker run -d --rm --name "$base_container" --network "$network" -p "0.0.0.0:187 -e "OPENAI_TARGET_API_URL=http://${mock_container}:8080" -e OPENAI_API_KEY=upstream-secret \ "$base_image" >/dev/null docker run -d --rm --name "$head_container" --network "$network" -p "0.0.0.0:18784:8787" "$head_image" >/dev/null +# Loopback-published container that binds 0.0.0.0 inside: the proxy cannot see +# the host publication, so the operator acknowledges the token-less open bind. docker run -d --rm --name "$local_container" --network "$network" -p "127.0.0.1:18786:8787" \ + -e HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 \ -e "OPENAI_TARGET_API_URL=http://${mock_container}:8080" -e OPENAI_API_KEY=upstream-secret \ "$head_image" --host 0.0.0.0 --port 8787 >/dev/null +# Without the acknowledgement or a token, the same launch must refuse to start +# and name both ways out. +refused_output="$(docker run --rm --network "$network" "$head_image" --host 0.0.0.0 --port 8787 2>&1)" \ + && { echo "token-less 0.0.0.0 bind started without acknowledgement" >&2; exit 1; } +grep -Fq HEADROOM_PROXY_TOKEN <<<"$refused_output" +grep -Fq HEADROOM_ALLOW_UNAUTHENTICATED_BIND <<<"$refused_output" docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$slim_image" \ | grep -Fxq 'HEADROOM_HOST=127.0.0.1' docker run --rm --entrypoint python3 "$slim_image" -c 'import headroom._core; print("rust_core=loaded")' @@ -182,4 +191,4 @@ ws_key="$(printf '%s' 'the sample nonce' | base64 | tr -d '\n')" ws_status="$(docker run --rm --name "$attacker_container" --network "$network" curlimages/curl:8.12.1 --silent --output /dev/null --write-out '%{http_code}' --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H "Sec-WebSocket-Key: ${ws_key}" "http://${host_ip}:18785/v1/live")" test "$ws_status" = "$ws_status_expected" printf 'base_ref=%s base_digest=%s head_digest=%s slim_digest=%s runtime_slim_config=loopback rust_core=loaded local_completion=200 attacker_loopback=000 ws_status=%s\n' "$base_ref" "$(docker image inspect --format='{{.Id}}' "$base_image")" "$(docker image inspect --format='{{.Id}}' "$head_image")" "$(docker image inspect --format='{{.Id}}' "$slim_image")" "$ws_status" -echo "docker bind security: fixture forwarding, loopback isolation, built-image health, slim-image loopback config, and token controls passed" +echo "docker bind security: fixture forwarding, loopback isolation, open-bind refusal, built-image health, slim-image loopback config, and token controls passed" diff --git a/e2e/wrap/tools/package-lock.json b/e2e/wrap/tools/package-lock.json index 1b004ac182..f2a37a0813 100644 --- a/e2e/wrap/tools/package-lock.json +++ b/e2e/wrap/tools/package-lock.json @@ -8,8 +8,8 @@ "name": "headroom-wrap-e2e-tools", "version": "1.0.0", "dependencies": { - "@openai/codex": "0.155.1", - "openclaw": "2026.9.5" + "@openai/codex": "0.158.0", + "openclaw": "2026.9.6" } }, "node_modules/@agentclientprotocol/sdk": { @@ -22,9 +22,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", - "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "version": "0.125.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.125.0.tgz", + "integrity": "sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1", @@ -62,9 +62,9 @@ } }, "node_modules/@clack/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.0.tgz", - "integrity": "sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -75,12 +75,12 @@ } }, "node_modules/@clack/prompts": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.0.tgz", - "integrity": "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", "license": "MIT", "dependencies": { - "@clack/core": "1.5.0", + "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -103,9 +103,9 @@ } }, "node_modules/@google/genai": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", - "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.22.0.tgz", + "integrity": "sha512-NE90Nl0AP75YZ2tGxXjQQs6Gkunma7a1JmPCx5g5CxvjHXshmuI2aNoPJ1xLrRixjkNxog898oylM+MyNudUQw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -582,9 +582,9 @@ ] }, "node_modules/@mistralai/mistralai": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.6.4.tgz", - "integrity": "sha512-PPt4GyJqs2hEsWrYCJZK5f0ORmT+L2MSm75LVGD7kBLf6ZKsoDpld/FRBQXr8xG6iFCBOFJFYzvGYhUb+UCkbw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.7.0.tgz", + "integrity": "sha512-zCrx6MtNpcz+EL/Nqvg55QubSgmfwJfBmd3d3wSYfJGfnvlNnWcJYlxEW5xQpS3KR6A5qK9KUyRBwMDIWnyVGA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", @@ -614,9 +614,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -663,9 +663,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.155.1", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1.tgz", - "integrity": "sha512-02fAAGyBtlA1zPjEo3kTj/bOSYbPz5DvjLwRZJdV7weFFEDzNFOMjQGmZ/+5CuirYV0hE+AZTrnjzwXYU4AdAQ==", + "version": "0.158.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0.tgz", + "integrity": "sha512-GBhcKpQmVLsCtEP5mUf6WFye6QQgTKotwsXrYPM0GFmEsoOSahik6hkf2FHabX9kZBl0Y0/PQowLu7uYMKT8dg==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -674,19 +674,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.155.1-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.155.1-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.155.1-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.155.1-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.155.1-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.155.1-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.158.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.158.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.158.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.158.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.158.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.158.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.155.1-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1-darwin-arm64.tgz", - "integrity": "sha512-cYxzGcRRoBrncyHlR8ed4yXwcoVJZC1pipGULSyJkGFKXJw/Uu57BklvzayuAptjJIipamnOk32CfUkk1F0bLw==", + "version": "0.158.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0-darwin-arm64.tgz", + "integrity": "sha512-0OKSjlWY1j4Ld1fT87QttNw3Y2SthcXi4GcrWSHjleZg1n86eG3+shJl4Pv+siUmsBJhWlNmm6rRO4Yv8ZyQLg==", "cpu": [ "arm64" ], @@ -701,9 +701,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.155.1-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1-darwin-x64.tgz", - "integrity": "sha512-FDpc+PdELYlyDnhd76Ckm6jNLF+1n3x34Ygd4QLQger810Vkxx/InQ5LY5jwkecJYKcbvyhMmuxTspaj1dLZrA==", + "version": "0.158.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0-darwin-x64.tgz", + "integrity": "sha512-FrX1o3APrL7F6QkO8z08Rq8lJitH2sNI7pkebA02eYA103hDs3fyy9d32zeLLQJUeAhmZA4pV9xJR4m7cVyNpQ==", "cpu": [ "x64" ], @@ -718,9 +718,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.155.1-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1-linux-arm64.tgz", - "integrity": "sha512-X3fRXm2orhJ3KeB8LgKym4XDUiQaqaOGuaa181bcHTsQI7C8m6tcQQbQsKDzT/2IibikzgYL82jvsgMbq43jww==", + "version": "0.158.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0-linux-arm64.tgz", + "integrity": "sha512-T9AgGcoU7HNsxJ4prT/R9YvztyEmz9kWP/VlT2cbCop4PVwiqfG9YMJtLo4j2ScewvSaTl4sQFwla+fwGpoRZg==", "cpu": [ "arm64" ], @@ -735,9 +735,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.155.1-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1-linux-x64.tgz", - "integrity": "sha512-atv3HF0mubqB0J/XkQ2JopqKzXJ+/7aQtTB2MkJ9MrraujMIz8zbCCLylLkN3PzpVGTJzzQFN/wD1oq8oJPJKg==", + "version": "0.158.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0-linux-x64.tgz", + "integrity": "sha512-mY12GZPM8TuOWVGxCyNV2NSA8t1uIupm9Nhu9VeQVEvvxBKN08U8bLH+vn7oISUHZPC8bwhROIbo/ZUxqV6XMg==", "cpu": [ "x64" ], @@ -752,9 +752,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.155.1-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1-win32-arm64.tgz", - "integrity": "sha512-k5x8VO1aF8Xx/nuh1P31TeBgs11WA6i2GJiHqx5YCndFbWzOzUz6aeBaq9+PO6Qfe/Ivennh3I1FKJBU6Q8mpg==", + "version": "0.158.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0-win32-arm64.tgz", + "integrity": "sha512-Jw1u0q0+5PG97jPkINxE3UCFtsYBN8Af+IjjM0zlCO675Sv5lNsXU2K9aIaXwVeQIKw8q2lbPAR4SEkq2rSOoA==", "cpu": [ "arm64" ], @@ -769,9 +769,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.155.1-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.155.1-win32-x64.tgz", - "integrity": "sha512-MO+cCZrgU0Ec7lJP/5NsTe5obJ9/qtRMkQUK0jYWTY1omxLA3lp5IOD2IAmsejlEJB931XRo51LZ7hl178CDjA==", + "version": "0.158.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.158.0-win32-x64.tgz", + "integrity": "sha512-IaUmY11Zdqa/Zok6kE0Z5375pXtClKRbS8P1Gh2C73Aq65CaGn9N8gT6BXGC3+XD6yamAIiEQW5xM842UCCGow==", "cpu": [ "x64" ], @@ -785,15 +785,15 @@ } }, "node_modules/@openclaw/ai": { - "version": "2026.9.5", - "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.9.5.tgz", - "integrity": "sha512-RBchFFYjhvbN5FM8oBY8N8AGH2k6LTFaHohbQcdnhM7ZvfEjaWRNBmgGw8Q2RqfKjHtEL5sHm7p+EWvFTvfjIw==", + "version": "2026.9.6", + "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.9.6.tgz", + "integrity": "sha512-Vu9UbjzVRh1982SL2uIZsD22CPIDjnKDn6z0TG+BhampQgq8evvc/eIubHHjC6l5GktHbxd1PD4Q/DVj+3LWeA==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "0.124.0", - "@google/genai": "2.21.0", - "@mistralai/mistralai": "2.6.4", - "openai": "7.12.1", + "@anthropic-ai/sdk": "0.125.0", + "@google/genai": "2.22.0", + "@mistralai/mistralai": "2.7.0", + "openai": "7.15.0", "partial-json": "0.1.7", "typebox": "1.3.30" }, @@ -802,28 +802,28 @@ } }, "node_modules/@openclaw/fs-safe": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.13.1.tgz", - "integrity": "sha512-nfsEjPirAKotKBaQoQt10JbZx3RwkFIcv2Ujj0m3DCJCP5Jre3AUnMatVinsNL/W60IL4XeRWEXCvlra7edx7g==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.18.1.tgz", + "integrity": "sha512-p56o1BFSQ2Ayc2+WhTJHfiWNyEtl8DOY15uUCUV/lvQILt+58651v964SpqNEgCvaXdccJvK/YHW5+ivOYKt0A==", "license": "MIT", "engines": { "node": ">=22" }, "optionalDependencies": { - "@openclaw/fs-safe-darwin-arm64": "0.13.1", - "@openclaw/fs-safe-darwin-x64": "0.13.1", - "@openclaw/fs-safe-linux-arm64-gnu": "0.13.1", - "@openclaw/fs-safe-linux-arm64-musl": "0.13.1", - "@openclaw/fs-safe-linux-x64-gnu": "0.13.1", - "@openclaw/fs-safe-linux-x64-musl": "0.13.1", - "@openclaw/fs-safe-win32-x64-msvc": "0.13.1", + "@openclaw/fs-safe-darwin-arm64": "0.18.1", + "@openclaw/fs-safe-darwin-x64": "0.18.1", + "@openclaw/fs-safe-linux-arm64-gnu": "0.18.1", + "@openclaw/fs-safe-linux-arm64-musl": "0.18.1", + "@openclaw/fs-safe-linux-x64-gnu": "0.18.1", + "@openclaw/fs-safe-linux-x64-musl": "0.18.1", + "@openclaw/fs-safe-win32-x64-msvc": "0.18.1", "jszip": "^3.10.2" } }, "node_modules/@openclaw/fs-safe-darwin-arm64": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-darwin-arm64/-/fs-safe-darwin-arm64-0.13.1.tgz", - "integrity": "sha512-dyPCOfMVJMXaP2yJIIyZrvK3TyQpCEduhvvfH3Zbcw/4SrdKP574qghgJM+VCVg2T9TEHTN4YBNO6z52iiMI3g==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-darwin-arm64/-/fs-safe-darwin-arm64-0.18.1.tgz", + "integrity": "sha512-UlQgyu9j9aA8UUBceB41Ts/4rOLfBdtsowzQkekYYRvSNkSpRu1Gsp/jbLgT7qNYKBQHF9GTZiQqlSmpLj3Z9g==", "cpu": [ "arm64" ], @@ -837,9 +837,9 @@ } }, "node_modules/@openclaw/fs-safe-darwin-x64": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-darwin-x64/-/fs-safe-darwin-x64-0.13.1.tgz", - "integrity": "sha512-FJQWzC2ov0anu1sBiK796bKLRQhxvwgi17KPf6Wqt0OAMbC9UVrDuF22Fb4ASPn6CS8+vou64/qurc9XK1OjIA==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-darwin-x64/-/fs-safe-darwin-x64-0.18.1.tgz", + "integrity": "sha512-02qKF5+CNkPhZmlp3K8hutfF47TXb/w6a4mnxPVoL34E2FU2Ef7BGCQdeG6VdhZ/pIrUIFp3+d77hftUFvXzIw==", "cpu": [ "x64" ], @@ -853,9 +853,9 @@ } }, "node_modules/@openclaw/fs-safe-linux-arm64-gnu": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-arm64-gnu/-/fs-safe-linux-arm64-gnu-0.13.1.tgz", - "integrity": "sha512-c/0+bdcYrLz0j/I37la4OcuRteVhqu1usLJqzax7A74dxIAtavARY36hyYIofxxRZPMYayBouB1DzAIpQviXCw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-arm64-gnu/-/fs-safe-linux-arm64-gnu-0.18.1.tgz", + "integrity": "sha512-U504q6xl4pQFBtOSP3n2lziA6yqWWVQJVGVDGPdVkK2OXu1D4LJdYdkNYhEfNjy63B2HerOG9qdWpoTQdvwH2Q==", "cpu": [ "arm64" ], @@ -872,9 +872,9 @@ } }, "node_modules/@openclaw/fs-safe-linux-arm64-musl": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-arm64-musl/-/fs-safe-linux-arm64-musl-0.13.1.tgz", - "integrity": "sha512-Z5N6wQIJsKFa/GT6nSo2d70TaEydNcWTq3JlYWZxfz1lb2QqbRX7drh8S+doc74dxy+KoVUy+nb1DUYOfX5hEw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-arm64-musl/-/fs-safe-linux-arm64-musl-0.18.1.tgz", + "integrity": "sha512-dbBxUQMtAquszOxZGhOrTbxontqSoTlUW/Plz5Bx7/5Wvs2JycD4br1qvCYMiFBfriGvGYWprKuTAFmZaDrXeg==", "cpu": [ "arm64" ], @@ -891,9 +891,9 @@ } }, "node_modules/@openclaw/fs-safe-linux-x64-gnu": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-x64-gnu/-/fs-safe-linux-x64-gnu-0.13.1.tgz", - "integrity": "sha512-necoU8nn6wf04dWk+imTWh+BAT0bwH7MG6tHzl6h/qNNiWFAkcJja85lD+prqKt2e8652HtfGvrSiZfusjzTRg==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-x64-gnu/-/fs-safe-linux-x64-gnu-0.18.1.tgz", + "integrity": "sha512-FWug2NdenYPJIVdtdzPH1xqBo5a/HYlZJutKEQkxSSM59Z/tBYiY24MsrjdYC5u2+2zwXPv38noB/hZ89Ec1kQ==", "cpu": [ "x64" ], @@ -910,9 +910,9 @@ } }, "node_modules/@openclaw/fs-safe-linux-x64-musl": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-x64-musl/-/fs-safe-linux-x64-musl-0.13.1.tgz", - "integrity": "sha512-zrZavePtOGS28uoXwTQas4b91xEAyLoOCplwFhnNfBQ0Tgz9jT2c1bLaD7vNlsWmRYsz9XGcMl4KiXrtruanSg==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-linux-x64-musl/-/fs-safe-linux-x64-musl-0.18.1.tgz", + "integrity": "sha512-1PS9KtPpDnznKn2LCc2jCZgjqRcS26ZHpNniqs8S0ac8v0I+MsywkXEr0UwD/8ZUjSFJ5vDL8q+yn61J0HaLRg==", "cpu": [ "x64" ], @@ -929,9 +929,9 @@ } }, "node_modules/@openclaw/fs-safe-win32-x64-msvc": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-win32-x64-msvc/-/fs-safe-win32-x64-msvc-0.13.1.tgz", - "integrity": "sha512-69r8H9Oz3yMglTQEkE982973QDmnvcrO5L74lkMa29jxQitt/efI7fUtDcs6nHu3+EhVwSF4iemwNtBdpqyuVw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe-win32-x64-msvc/-/fs-safe-win32-x64-msvc-0.18.1.tgz", + "integrity": "sha512-SRcEUSFzQvISA6q2Mvax9AjrM7DVuKyn8Pd3fGZFjFPhCjL/Ld3tMkg2DWQc/hGYg/5Km2XtEWvZwM0l/xkukQ==", "cpu": [ "x64" ], @@ -1076,27 +1076,27 @@ "license": "MIT" }, "node_modules/@trycua/cua-driver": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver/-/cua-driver-0.24.0.tgz", - "integrity": "sha512-OVYuBBvo7HNxGmqZDaWlfQ5tdr7FruA0YfytJ0p+aTZ9QF/1T8G5nQNsLVJht7JBAybktxO6JW+lxBNn9QHp8A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver/-/cua-driver-0.28.2.tgz", + "integrity": "sha512-18DHs68WpIdKtkAMecd5mLKwJtT854gXzie24Ag0QMxveRNgzKphAje51t0oeI7ZnHetyXKDAQUr5fQyZu7S6Q==", "license": "MIT", "dependencies": { "@ubjs/core": "0.31.0-3", "@ubjs/node": "0.31.0-3" }, "optionalDependencies": { - "@trycua/cua-driver-darwin-arm64": "0.24.0", - "@trycua/cua-driver-darwin-x64": "0.24.0", - "@trycua/cua-driver-linux-arm64-gnu": "0.24.0", - "@trycua/cua-driver-linux-x64-gnu": "0.24.0", - "@trycua/cua-driver-win32-arm64-msvc": "0.24.0", - "@trycua/cua-driver-win32-x64-msvc": "0.24.0" + "@trycua/cua-driver-darwin-arm64": "0.28.2", + "@trycua/cua-driver-darwin-x64": "0.28.2", + "@trycua/cua-driver-linux-arm64-gnu": "0.28.2", + "@trycua/cua-driver-linux-x64-gnu": "0.28.2", + "@trycua/cua-driver-win32-arm64-msvc": "0.28.2", + "@trycua/cua-driver-win32-x64-msvc": "0.28.2" } }, "node_modules/@trycua/cua-driver-darwin-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver-darwin-arm64/-/cua-driver-darwin-arm64-0.24.0.tgz", - "integrity": "sha512-V6C580ZFIYmB6fxjqM3eEoXahvzGjWc34I1SoVSBjDRjQSn2fXLldlNsCBxEUHs4TH/JopTyP1hTChbNtCC06w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver-darwin-arm64/-/cua-driver-darwin-arm64-0.28.2.tgz", + "integrity": "sha512-Eeo62jJjxi67/XwSQKvxpJQMmJg7pjLqGa4vZTxLj3KUmjfGYjwt3wifLl82yU0AfJs6icB7V1uu6sYE+nm9mg==", "cpu": [ "arm64" ], @@ -1107,9 +1107,9 @@ ] }, "node_modules/@trycua/cua-driver-darwin-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver-darwin-x64/-/cua-driver-darwin-x64-0.24.0.tgz", - "integrity": "sha512-4KmFTUsGegTXmt+Crr/M+Zf/92zI+tGmJWFfM3CkCNGaCBw+wqJFmAYp9mVaR2F+zmdpaWyptB/movaHC88Lcg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver-darwin-x64/-/cua-driver-darwin-x64-0.28.2.tgz", + "integrity": "sha512-n7iWl2iqIyPoDpCJ3hyK2gKZqu0RTolabRptmZKYPzRSTbRbzO3mejOa+CXHsJjVbM4+FyDJDNn5NE2CZLMdXg==", "cpu": [ "x64" ], @@ -1120,9 +1120,9 @@ ] }, "node_modules/@trycua/cua-driver-linux-arm64-gnu": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver-linux-arm64-gnu/-/cua-driver-linux-arm64-gnu-0.24.0.tgz", - "integrity": "sha512-SWiyMz8z6Oax/Q+Fp2zN4WjxcXE1pSQH9SsET63W2+C6t2CZdhLzypB1h4xNStcX8eCQZ/dl0ayiV2h0U2c/Zw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver-linux-arm64-gnu/-/cua-driver-linux-arm64-gnu-0.28.2.tgz", + "integrity": "sha512-ZFrYmA53ooEGyImEHo7cxYxgyXidP6Mkxc1KYiM8KoFmeHFSX3TmuKDsVV9dwZGKrhAOJBvQTvLvlb85RlccQg==", "cpu": [ "arm64" ], @@ -1136,9 +1136,9 @@ ] }, "node_modules/@trycua/cua-driver-linux-x64-gnu": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver-linux-x64-gnu/-/cua-driver-linux-x64-gnu-0.24.0.tgz", - "integrity": "sha512-7FDtyemjyaZwNIOahfYPr6NcJOr86i3WXGcaJ9Q+a5wG13srkr3vhA0K7mPQcGd/tZTyZAulXA0fnjYv7oZiXg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver-linux-x64-gnu/-/cua-driver-linux-x64-gnu-0.28.2.tgz", + "integrity": "sha512-mZp8rRyaT0/gUXnHGAC53EKA6WHKvRbliacMVsiwf/EEUOLNtz6pRNfhYyF4GLT6jHbC/Xwu8V9kpntz9Tssdg==", "cpu": [ "x64" ], @@ -1152,9 +1152,9 @@ ] }, "node_modules/@trycua/cua-driver-win32-arm64-msvc": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver-win32-arm64-msvc/-/cua-driver-win32-arm64-msvc-0.24.0.tgz", - "integrity": "sha512-NktveoX98qafCjgBePzqTsHqjlFHdz6ositvEtwVTS3xA7hNEgCsr9w55sRQYuUSGfz5xjxpFioSn6Mh/f5brw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver-win32-arm64-msvc/-/cua-driver-win32-arm64-msvc-0.28.2.tgz", + "integrity": "sha512-prShQ7VSVXMkytMGj+d43u54/9H3K0nJT5OOAqT3S78XATw7dlu1ESdEacr/J2lNcmn5mxwEy+i+9HRmEt0DJw==", "cpu": [ "arm64" ], @@ -1165,9 +1165,9 @@ ] }, "node_modules/@trycua/cua-driver-win32-x64-msvc": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@trycua/cua-driver-win32-x64-msvc/-/cua-driver-win32-x64-msvc-0.24.0.tgz", - "integrity": "sha512-8tudsgdnfgCgeavIyhIOVCAAkfKezTkhTaZvPqZnikYN+5xKrO4o+vPhyyuEurYZLBLKluAxRwGr9DP/7syZjw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@trycua/cua-driver-win32-x64-msvc/-/cua-driver-win32-x64-msvc-0.28.2.tgz", + "integrity": "sha512-H7pUIwbrKCqkyDBcI4ovk1ggN+MOmFtATGl0fokPkD7dp1EHGn5I9vRJEPVvqjP0BTwdlBJ8NucIy0g2eFK86Q==", "cpu": [ "x64" ], @@ -1178,9 +1178,9 @@ ] }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -2314,9 +2314,9 @@ } }, "node_modules/file-type": { - "version": "22.0.2", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.2.tgz", - "integrity": "sha512-0H8TsCUGBLx+V5adH3EY52hTAcyLKbV1D4gq5cIOJ6DnQAHeV9Z2Hhuc5CoBX4YmvB2oL+JIC84z0qO7JsCoNw==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.1.0.tgz", + "integrity": "sha512-fbvf9u6jnHWr1EP6V8R99E9iLMhNLXgZkUzWBiPpJ5/89UJ/Atuxjn5YXR+XaZWo2hc2/8mGfTez+NDAgOcpLA==", "license": "MIT", "dependencies": { "@tokenizer/inflate": "^0.4.1", @@ -2613,9 +2613,9 @@ } }, "node_modules/hono": { - "version": "4.13.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", - "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "version": "4.13.10", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.10.tgz", + "integrity": "sha512-dQuLsa5oO+47QVMVMaaD9cIv8ctmVtK1iRvwWngkfloFJMeFeuoUFDswIqZGxmGX3hrRzREgEArjkK9OgsQEhA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3289,15 +3289,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/node-addon-api": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", - "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -3391,17 +3382,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -3489,9 +3469,9 @@ } }, "node_modules/openai": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-7.12.1.tgz", - "integrity": "sha512-D0d8NevCWPe+cxE5kw/ZaBRh1Od4FfhRmD6FlNeLN1j96Xll5MYEJTbaFF9/F3eQHJANjrSuq2nWBCpVT+TmnQ==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.15.0.tgz", + "integrity": "sha512-2DIwesnPSduw68MFx7nAdgvshrgvS8WOan5W8+tgUMlAy/tY+Q41g80Pa+w/v64rHA6rtn+dketa8QvMVxU96w==", "license": "Apache-2.0", "engines": { "node": ">=22.0.0" @@ -3526,33 +3506,37 @@ } }, "node_modules/openclaw": { - "version": "2026.9.5", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.9.5.tgz", - "integrity": "sha512-TCO/ImVLh5HkF4tdfo7iriIa7kT6iYkIr/jR5ZOkePGFGhUx5Oe7DE716Y1DzzG2teRAVDdCjgJDu1A24Yta7w==", + "version": "2026.9.6", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.9.6.tgz", + "integrity": "sha512-Ie0kyQSCVfFqixsgVg39vevUDq01Ch5u3+7Yu5Y3qARczmdAe+lzp8bVnO9925rHiW/+CFp70zfORCyPmCH31g==", + "bundleDependencies": [ + "chrome-devtools-mcp" + ], "hasInstallScript": true, "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "1.4.0", - "@anthropic-ai/sdk": "0.124.0", - "@clack/core": "1.5.0", - "@clack/prompts": "1.8.0", + "@anthropic-ai/sdk": "0.125.0", + "@clack/core": "1.5.1", + "@clack/prompts": "1.8.1", "@earendil-works/pi-tui": "0.85.1", - "@google/genai": "2.21.0", + "@google/genai": "2.22.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "@homebridge/ciao": "1.3.12", "@lydell/node-pty": "1.2.0-beta.15", - "@mistralai/mistralai": "2.6.4", + "@mistralai/mistralai": "2.7.0", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "0.6.0", - "@openclaw/ai": "2026.9.5", - "@openclaw/fs-safe": "0.13.1", + "@openclaw/ai": "2026.9.6", + "@openclaw/fs-safe": "0.18.1", "@openclaw/proxyline": "0.3.12", "@silvia-odwyer/photon-node": "0.3.4", - "@trycua/cua-driver": "0.24.0", + "@trycua/cua-driver": "0.28.2", "acorn": "8.18.0", "chalk": "6.0.0", "chokidar": "5.0.0", + "chrome-devtools-mcp": "1.8.0", "clawpdf": "0.3.2", "commander": "15.0.0", "croner": "10.0.1", @@ -3561,7 +3545,7 @@ "entities": "8.1.0", "execa": "10.0.1", "express": "5.2.1", - "file-type": "22.0.2", + "file-type": "22.1.0", "grammy": "1.46.0", "highlight.js": "11.12.0", "hosted-git-info": "10.1.1", @@ -3577,19 +3561,17 @@ "minimatch": "10.2.6", "ms": "2.1.3", "node-edge-tts": "1.2.10", - "openai": "7.12.1", + "openai": "7.15.0", "p-limit": "7.3.2", - "p-map": "7.0.7", + "p-map": "7.0.8", "partial-json": "0.1.7", "playwright-core": "1.63.0", "pretty-ms": "9.3.1", "qrcode": "1.5.4", - "quickjs-wasi": "3.6.0", "rastermill": "0.3.3", "semver": "7.8.5", "tar": "7.5.22", - "tree-sitter-bash": "0.25.1", - "tslog": "5.1.0", + "tslog": "5.2.0", "typebox": "1.3.30", "typescript": "6.0.3", "undici": "8.10.2", @@ -3597,7 +3579,7 @@ "web-tree-sitter": "0.27.0", "ws": "8.21.3", "yaml": "2.9.0", - "zod": "4.5.4" + "zod": "4.6.5" }, "bin": { "openclaw": "openclaw.mjs" @@ -3609,6 +3591,30 @@ "sqlite-vec": "0.1.9" } }, + "node_modules/openclaw/node_modules/chrome-devtools-mcp": { + "version": "1.8.0", + "inBundle": true, + "license": "Apache-2.0", + "bin": { + "chrome-devtools": "build/src/bin/chrome-devtools.js", + "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "@blackwell-systems/gcf": "^2.2.2", + "@toon-format/toon": "^4.1.0" + }, + "peerDependenciesMeta": { + "@blackwell-systems/gcf": { + "optional": true + }, + "@toon-format/toon": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.2.tgz", @@ -3652,9 +3658,9 @@ } }, "node_modules/p-map": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", - "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", "license": "MIT", "engines": { "node": ">=18" @@ -3870,12 +3876,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/quickjs-wasi": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.0.tgz", - "integrity": "sha512-/CNzMq42B8ThzUpzjLSF8K50ntVLV3RREyT8zr4wHaP2CZSHFhPkqg3E1GULmTerehf8Bo+UguQ+4LoOMCTC6A==", - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -4446,25 +4446,6 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, - "node_modules/tree-sitter-bash": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz", - "integrity": "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.2.1", - "node-gyp-build": "^4.8.2" - }, - "peerDependencies": { - "tree-sitter": "^0.25.0" - }, - "peerDependenciesMeta": { - "tree-sitter": { - "optional": true - } - } - }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -4478,15 +4459,15 @@ "license": "0BSD" }, "node_modules/tslog": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/tslog/-/tslog-5.1.0.tgz", - "integrity": "sha512-g77ALovust2HNBX/63ePEJwetIeX0Vi/dlzqBO+ZAg+aERB54KMmaz/khNPxhFpaxKQ6RQj65sK6vIhj1SJgjQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-5.2.0.tgz", + "integrity": "sha512-bD0FpBIFZ1OInp7uzQmEn1SJe6feTkVTCudw/27ILuISbVZtTryXqauxyCWTa1I8ZgTm9OGQ6YrJPKM+6QTeJA==", "license": "MIT", "bin": { "tslog": "esm/subpaths/cli.js" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/fullstack-build/tslog?sponsor=1" @@ -4549,9 +4530,9 @@ "license": "ISC" }, "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", "license": "MIT", "engines": { "node": ">=18" @@ -4840,9 +4821,9 @@ } }, "node_modules/zod": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", - "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/e2e/wrap/tools/package.json b/e2e/wrap/tools/package.json index 2f5d2310ff..24f8115d59 100644 --- a/e2e/wrap/tools/package.json +++ b/e2e/wrap/tools/package.json @@ -4,7 +4,10 @@ "private": true, "description": "Locked CLI dependencies for the Docker wrap E2E harness", "dependencies": { - "@openai/codex": "0.155.1", - "openclaw": "2026.9.5" + "@openai/codex": "0.158.0", + "openclaw": "2026.9.6" + }, + "overrides": { + "@modelcontextprotocol/sdk": "1.32.1" } } diff --git a/examples/langchain_demo/run_comparison.py b/examples/langchain_demo/run_comparison.py index c771cf6bf0..d547943527 100644 --- a/examples/langchain_demo/run_comparison.py +++ b/examples/langchain_demo/run_comparison.py @@ -255,16 +255,13 @@ def run_agent_headroom(scenario: dict, api_key: str) -> AgentRun: ) # Wrap with Headroom - config = HeadroomConfig( - smart_crusher_threshold=500, # Compress tool outputs > 500 tokens - smart_crusher_max_items=20, # Keep max 20 items - cache_alignment=True, - rolling_window=True, - ) + # Default settings. The pipeline builds its ContentRouter from the router's + # own defaults, so HeadroomConfig.smart_crusher would not change what runs. + config = HeadroomConfig() headroom_model = HeadroomChatModel( wrapped_model=base_model, - headroom_config=config, + config=config, ).bind_tools(tools) # Build conversation @@ -327,7 +324,7 @@ def run_agent_headroom(scenario: dict, api_key: str) -> AgentRun: duration_ms = (time.time() - start_time) * 1000 # Get Headroom metrics - tokens_saved = headroom_model.get_total_tokens_saved() + tokens_saved = headroom_model.total_tokens_saved return AgentRun( scenario=scenario["name"], @@ -456,7 +453,7 @@ def run_simulation(): print(f"\n Total tool output: {total_tool_tokens:,} tokens") print(f" With 3 iterations, baseline input would be: ~{total_tool_tokens * 2:,} tokens") - print(f" With Headroom (20 items max), estimated: ~{total_tool_tokens // 5:,} tokens") + print(f" With Headroom (rough 5x estimate), estimated: ~{total_tool_tokens // 5:,} tokens") print( f" Estimated savings: ~{total_tool_tokens * 2 - total_tool_tokens // 5:,} tokens (~80%)" ) @@ -498,8 +495,7 @@ def print_summary(baseline_runs: list[AgentRun], headroom_runs: list[AgentRun]): Headroom reduced input tokens by {pct_saved:.1f}% across all scenarios. Key optimizations applied: -- SmartCrusher: Compressed tool outputs from 50-200 items to ~20 relevant items -- CacheAligner: Stabilized system prompt for better cache hits +- SmartCrusher: Compressed the large JSON tool outputs (50-200 items each) - Context preserved: Agent still found the right information This translates to: diff --git a/examples/mcp_demo/README.md b/examples/mcp_demo/README.md new file mode 100644 index 0000000000..96b20dab0b --- /dev/null +++ b/examples/mcp_demo/README.md @@ -0,0 +1,33 @@ +# MCP + Headroom Demo + +Real-world demonstration of Headroom compression on MCP (Model Context Protocol) tool outputs. + +## Quick Start + +```bash +# Show compression on mock MCP tool outputs (no API key needed) +PYTHONPATH=. python -m examples.mcp_demo.show_compression + +# Show the minimal BEFORE/AFTER code change for MCP integration +PYTHONPATH=. python -m examples.mcp_demo.show_before_after + +# Run the full agent evaluation (requires OPENAI_API_KEY) +export OPENAI_API_KEY='your-key-here' +PYTHONPATH=. python -m examples.mcp_demo.run_agent_eval +``` + +## What each script does + +| Script | Purpose | +|--------|---------| +| `show_compression.py` | Compresses mock MCP tool results (Slack search, database queries, GitHub issues, log analysis) via `compress_tool_result_with_metrics` and prints before/after sizes. | +| `show_before_after.py` | Prints the minimal code diff needed to add Headroom compression to MCP tool outputs in your host application. | +| `run_agent_eval.py` | Simulates an agent with multiple MCP tools and tests whether compression preserves the information needed to answer correctly. Deterministic test-data generators keep the eval reproducible. | +| `mock_mcp_servers.py` | Test-data generators simulating real MCP server outputs. | + +## Key imports + +```python +from headroom.integrations.mcp import compress_tool_result_with_metrics +from headroom.providers import OpenAIProvider +``` diff --git a/headroom/__init__.py b/headroom/__init__.py index 8c14ea748d..95a6bdb942 100644 --- a/headroom/__init__.py +++ b/headroom/__init__.py @@ -83,7 +83,13 @@ # ~microseconds. See `headroom/_ort.py` for the full story. ensure_ort_dylib_pinned() -from .compress import CompressConfig, CompressResult, compress, compress_spreadsheet # noqa: E402 +from .compress import ( # noqa: E402 + CompressConfig, + CompressResult, + compress, + compress_spreadsheet, + densify, +) # Keep a real callable bound for the one-function compression API so # `from headroom import compress` is never shadowed by the submodule object. @@ -119,6 +125,7 @@ "DiffArtifact", "RequestMetrics", "SimulationResult", + "MessageDecision", "TransformDiff", "TransformResult", "WasteSignals", @@ -174,9 +181,13 @@ "EmbedderBackend", # One-function compression API "compress", + "densify", "compress_spreadsheet", "CompressConfig", "CompressResult", + # Reverse densified tool output (for consumers of mode="agent" output) + "expand_compacted", + "is_compacted", # Hooks "CompressionHooks", "CompressContext", @@ -223,6 +234,7 @@ "DiffArtifact": ("headroom.config", "DiffArtifact"), "RequestMetrics": ("headroom.config", "RequestMetrics"), "SimulationResult": ("headroom.config", "SimulationResult"), + "MessageDecision": ("headroom.config", "MessageDecision"), "TransformDiff": ("headroom.config", "TransformDiff"), "TransformResult": ("headroom.config", "TransformResult"), "WasteSignals": ("headroom.config", "WasteSignals"), @@ -271,7 +283,10 @@ "reset_otel_metrics": ("headroom.observability", "reset_otel_metrics"), # One-function API "compress": ("headroom.compress", "compress"), + "densify": ("headroom.compress", "densify"), "compress_spreadsheet": ("headroom.compress", "compress_spreadsheet"), + "expand_compacted": ("headroom.transforms.compaction_codec", "expand_compacted"), + "is_compacted": ("headroom.transforms.compaction_codec", "is_compacted"), # Hooks "CompressionHooks": ("headroom.hooks", "CompressionHooks"), "CompressContext": ("headroom.hooks", "CompressContext"), diff --git a/headroom/agent_savings.py b/headroom/agent_savings.py index b08f52cbed..000464c1a7 100644 --- a/headroom/agent_savings.py +++ b/headroom/agent_savings.py @@ -5,7 +5,7 @@ import logging import os from collections.abc import MutableMapping -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Protocol logger = logging.getLogger(__name__) @@ -336,8 +336,9 @@ def proxy_pipeline_kwargs(config: object) -> dict[str, object]: 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 + compress_user_messages = getattr(config, "compress_user_messages", None) + if compress_user_messages is not None: + kwargs["compress_user_messages"] = bool(compress_user_messages) compress_system_messages = getattr(config, "compress_system_messages", None) if compress_system_messages is not None: @@ -387,7 +388,9 @@ def proxy_pipeline_kwargs(config: object) -> dict[str, object]: return kwargs -def seed_proxy_env_defaults(env: MutableMapping[str, str] | None = None) -> None: +def seed_proxy_env_defaults( + env: MutableMapping[str, str] | None = None, +) -> frozenset[str]: """Seed the process env with the savings-profile defaults (default: coding). Call at proxy EXECUTABLE entry points (the ``headroom proxy`` command and the @@ -400,21 +403,8 @@ def seed_proxy_env_defaults(env: MutableMapping[str, str] | None = None) -> None keep clean (unseeded) defaults and test isolation is preserved. """ target = os.environ if env is None else env + before = set(target) # apply_agent_savings_env_defaults honors an explicit HEADROOM_SAVINGS_PROFILE # already in the env and otherwise falls back to DEFAULT_PROFILE (coding). apply_agent_savings_env_defaults(target) - - -def with_target_savings( - profile: AgentSavingsProfile, - target_savings: float, -) -> AgentSavingsProfile: - """Return a copy of ``profile`` adjusted to a specific savings target.""" - - if not 0 < target_savings < 1: - raise ValueError("target_savings must be between 0 and 1") - return replace( - profile, - target_savings=target_savings, - target_ratio=round(1 - target_savings, 4), - ) + return frozenset(key for key in target if key not in before) diff --git a/headroom/backends/anyllm.py b/headroom/backends/anyllm.py index b7a089bf70..d3ea6c0635 100644 --- a/headroom/backends/anyllm.py +++ b/headroom/backends/anyllm.py @@ -12,9 +12,11 @@ from collections.abc import AsyncIterator from typing import Any, cast +from headroom.proxy.public_errors import client_message from headroom.utils import format_exception_message from .base import Backend, BackendResponse, StreamEvent +from .litellm import _convert_anthropic_tool, _convert_tool_choice logger = logging.getLogger(__name__) @@ -27,44 +29,6 @@ AnyLLM = None # type: ignore -def _convert_anthropic_tool(tool: dict[str, Any]) -> dict[str, Any]: - """Convert an Anthropic tool definition to the OpenAI function shape. - - any-llm speaks OpenAI, so an Anthropic ``{name, description, input_schema}`` - tool must become ``{type: function, function: {name, description, - parameters}}`` before it is forwarded, or the provider ignores/rejects the - tools array and the model never calls a tool. Mirrors the LiteLLM backend's - converter so both OpenAI-compatible backends send the same shape. - """ - func: dict[str, Any] = {"name": tool.get("name", "")} - if "description" in tool: - func["description"] = tool["description"] - if "input_schema" in tool: - func["parameters"] = tool["input_schema"] - return {"type": "function", "function": func} - - -def _convert_tool_choice(choice: Any) -> Any: - """Convert an Anthropic ``tool_choice`` to the OpenAI shape (mirrors LiteLLM). - - Anthropic: ``{"type": "auto"}``, ``{"type": "any"}``, ``{"type": "tool", - "name": ...}``. OpenAI: ``"auto"``, ``"required"``, ``{"type": "function", - "function": {"name": ...}}``. Passing the raw Anthropic dict through makes - the provider reject or ignore it. - """ - if isinstance(choice, str): - return choice - if isinstance(choice, dict): - choice_type = choice.get("type", "auto") - if choice_type == "auto": - return "auto" - if choice_type == "any": - return "required" - if choice_type == "tool": - return {"type": "function", "function": {"name": choice.get("name", "")}} - return "auto" - - class AnyLLMBackend(Backend): """Backend using any-llm for multi-provider support.""" @@ -505,7 +469,7 @@ async def stream_message( event_type="error", data={ "type": "error", - "error": {"type": "api_error", "message": error_message}, + "error": {"type": "api_error", "message": client_message(e, error_message)}, }, ) @@ -601,7 +565,9 @@ def _error_response(self, e: Exception, openai_format: bool = False) -> BackendR error_type = "api_error" status_code = 500 - error_message = format_exception_message(e) + # Provider API errors keep their text; transport failures are reduced + # to the public vocabulary (see proxy/public_errors). + error_message = client_message(e, format_exception_message(e)) error_str = str(e).lower() if "authentication" in error_str or "api_key" in error_str or "api key" in error_str: error_type = "invalid_api_key" if openai_format else "authentication_error" @@ -669,7 +635,7 @@ async def stream_openai_message( logger.error(f"any-llm OpenAI streaming error: {error_message}") error_data = { "error": { - "message": error_message, + "message": client_message(e, error_message), "type": "api_error", "code": "backend_error", } diff --git a/headroom/backends/litellm.py b/headroom/backends/litellm.py index ac3aad7acc..e1e13fc607 100644 --- a/headroom/backends/litellm.py +++ b/headroom/backends/litellm.py @@ -21,6 +21,7 @@ from dataclasses import dataclass, field from typing import Any +from headroom.proxy.public_errors import client_message from headroom.utils import format_exception_message from .base import Backend, BackendResponse, StreamEvent @@ -543,6 +544,25 @@ def _caller_key_travels_to(model: str, key: str) -> bool: return provider not in _REJECTS_ANTHROPIC_KEY +def _caller_key_from_headers(headers: dict[str, str]) -> str: + """Extract the caller's API key from inbound request headers. + + The ``Authorization`` auth-scheme token is case-insensitive per RFC 7235 + §2.1, so ``Authorization: bearer `` must be read the same as + ``Bearer ``. A case-sensitive ``startswith("Bearer ")`` dropped the + credential for a lowercase (or otherwise differently-cased) scheme, and the + request then fell back to the target provider's env key — which may not + exist, yielding a spurious upstream 401. Only the scheme is case-folded; the + credential itself is returned verbatim. Falls back to ``x-api-key`` when the + header carries no bearer credential (matching the prior behavior). + """ + auth_header = headers.get("authorization", headers.get("Authorization", "")) + scheme, sep, credentials = auth_header.partition(" ") + if sep and scheme.lower() == "bearer": + return credentials + return headers.get("x-api-key", "") + + def get_provider_config(provider: str) -> ProviderConfig: """Get provider config, with fallback for unknown providers.""" if provider in PROVIDER_REGISTRY: @@ -556,6 +576,44 @@ def get_provider_config(provider: str) -> ProviderConfig: ) +_ANTHROPIC_ERROR_TYPES = { + 400: "invalid_request_error", + 401: "authentication_error", + 403: "permission_error", + 404: "not_found_error", + 413: "request_too_large", + 422: "invalid_request_error", + 429: "rate_limit_error", +} + +_OPENAI_ERROR_TYPES = { + 400: "invalid_request_error", + 401: "invalid_api_key", + 403: "permission_error", + 404: "model_not_found", + 413: "invalid_request_error", + 422: "invalid_request_error", + 429: "rate_limit_exceeded", +} + + +def _upstream_client_error(exc: Exception, error_types: dict[int, str]) -> tuple[int, str] | None: + """Map a LiteLLM exception's own 4xx ``status_code`` to ``(status, error type)``. + + A 4xx status outside ``error_types`` keeps its status with the generic + ``invalid_request_error`` type. Returns None when the exception carries no + 4xx status, so the caller falls back to matching on the message. + """ + status = getattr(exc, "status_code", None) + if not isinstance(status, int) or isinstance(status, bool): + return None + if status in error_types: + return status, error_types[status] + if 400 <= status <= 499: + return status, "invalid_request_error" + return None + + def _anthropic_usage_from_litellm(litellm_usage: Any) -> dict[str, Any]: """Map LiteLLM usage to Anthropic-shape usage, surfacing cache tokens. @@ -591,6 +649,8 @@ def _anthropic_usage_from_litellm(litellm_usage: Any) -> dict[str, Any]: def _convert_anthropic_tool(tool: dict[str, Any]) -> dict[str, Any]: """Convert Anthropic tool format to OpenAI function format. + Shared with the any-llm backend, so both OpenAI-compatible backends send the same shape. + Anthropic: {"name": "...", "description": "...", "input_schema": {...}} OpenAI: {"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}} """ @@ -603,10 +663,10 @@ def _convert_anthropic_tool(tool: dict[str, Any]) -> dict[str, Any]: def _convert_tool_choice(choice: Any) -> Any: - """Convert Anthropic tool_choice to OpenAI format. + """Convert Anthropic tool_choice to OpenAI format (shared with the any-llm backend). - Anthropic: {"type": "auto"}, {"type": "any"}, {"type": "tool", "name": "..."} - OpenAI: "auto", "required", {"type": "function", "function": {"name": "..."}} + Anthropic: {"type": "auto"}, {"type": "any"}, {"type": "none"}, {"type": "tool", "name": "..."} + OpenAI: "auto", "required", "none", {"type": "function", "function": {"name": "..."}} """ if isinstance(choice, str): return choice @@ -616,6 +676,13 @@ def _convert_tool_choice(choice: Any) -> Any: return "auto" if choice_type == "any": return "required" + if choice_type == "none": + # Anthropic's {"type": "none"} means "do not use any tool this turn". + # Without this branch it fell through to the "auto" default below, + # inverting the instruction into "you may use tools" — the model + # could then call a tool the client explicitly forbade. OpenAI's + # equivalent is the string "none". + return "none" if choice_type == "tool": return {"type": "function", "function": {"name": choice.get("name", "")}} return "auto" @@ -1244,12 +1311,7 @@ async def send_message( # Forwarding x-api-key (e.g. sk-ant-dummy) would override their credentials. _env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker") if self.provider not in _env_auth_providers: - auth_header = headers.get("authorization", headers.get("Authorization", "")) - _caller_key = ( - auth_header[7:] - if auth_header.startswith("Bearer ") - else headers.get("x-api-key", "") - ) + _caller_key = _caller_key_from_headers(headers) # Only forward it if it can actually authenticate the TARGET. if _caller_key and _caller_key_travels_to(litellm_model, _caller_key): kwargs["api_key"] = _caller_key @@ -1274,13 +1336,19 @@ async def send_message( except Exception as e: error_message = format_exception_message(e) logger.error(f"LiteLLM error: {error_message}") + # Provider API errors keep their text; transport failures are + # reduced to the public vocabulary (see proxy/public_errors). + error_message = client_message(e, error_message) # Map to Anthropic error format error_type = "api_error" status_code = 500 error_str = str(e).lower() - if "authentication" in error_str or "credentials" in error_str: + upstream = _upstream_client_error(e, _ANTHROPIC_ERROR_TYPES) + if upstream is not None: + status_code, error_type = upstream + elif "authentication" in error_str or "credentials" in error_str: error_type = "authentication_error" status_code = 401 elif "rate" in error_str or "limit" in error_str: @@ -1364,12 +1432,7 @@ async def stream_message( # Forwarding x-api-key (e.g. sk-ant-dummy) would override their credentials. _env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker") if self.provider not in _env_auth_providers: - auth_header = headers.get("authorization", headers.get("Authorization", "")) - _caller_key = ( - auth_header[7:] - if auth_header.startswith("Bearer ") - else headers.get("x-api-key", "") - ) + _caller_key = _caller_key_from_headers(headers) # Only forward it if it can actually authenticate the TARGET. if _caller_key and _caller_key_travels_to(litellm_model, _caller_key): kwargs["api_key"] = _caller_key @@ -1420,6 +1483,14 @@ async def stream_message( final_input_tokens = 0 final_cache_read_tokens = 0 final_cache_write_tokens = 0 + # Real output-token count from the trailing usage chunk. The + # ``output_tokens`` counter incremented per content_block_delta below + # is only a delta *count* (one per SSE chunk), which undercounts the + # true token total several-fold. Prefer the provider's + # completion_tokens when the usage chunk carries it, exactly like the + # non-streaming path (_anthropic_usage_from_litellm), and fall back to + # the delta count only when no usage chunk arrives. + final_output_tokens = 0 # Extended thinking is BUFFERED, not streamed live. A thinking block # is only legal to replay if it leads the turn and carries a real @@ -1522,6 +1593,7 @@ def _flush_thinking() -> list[StreamEvent]: final_cache_write_tokens = int( getattr(cu, "cache_creation_input_tokens", 0) or 0 ) + final_output_tokens = int(getattr(cu, "completion_tokens", 0) or 0) if not hasattr(chunk, "choices") or not chunk.choices: continue @@ -1658,9 +1730,21 @@ def _flush_thinking() -> list[StreamEvent]: data={"type": "content_block_stop", "index": current_block_index}, ) - delta_usage: dict[str, Any] = {"output_tokens": output_tokens} + delta_usage: dict[str, Any] = {"output_tokens": final_output_tokens or output_tokens} if final_input_tokens or final_cache_read_tokens or final_cache_write_tokens: - delta_usage["input_tokens"] = final_input_tokens + # LiteLLM's prompt_tokens is the *total* prompt size, inclusive + # of the cache-read and cache-write tokens (Bedrock reports raw + # inputTokens and LiteLLM's AmazonConverseConfig._transform_usage + # adds cacheReadInputTokens + cacheWriteInputTokens onto it). + # Anthropic's input_tokens must exclude both, since the cache + # fields below report them separately and clients treat the three + # buckets as disjoint. Without the subtraction a cached streaming + # turn double-counts the cached prefix in input_tokens at the full + # input rate. Mirrors the non-streaming path in + # _anthropic_usage_from_litellm (#1345 / #1848). + delta_usage["input_tokens"] = max( + final_input_tokens - final_cache_read_tokens - final_cache_write_tokens, 0 + ) if final_cache_read_tokens: delta_usage["cache_read_input_tokens"] = final_cache_read_tokens if final_cache_write_tokens: @@ -1689,7 +1773,7 @@ def _flush_thinking() -> list[StreamEvent]: event_type="error", data={ "type": "error", - "error": {"type": "api_error", "message": error_message}, + "error": {"type": "api_error", "message": client_message(e, error_message)}, }, ) @@ -1751,12 +1835,7 @@ async def send_openai_message( # Forwarding x-api-key (e.g. sk-ant-dummy) would override their credentials. _env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker") if self.provider not in _env_auth_providers: - auth_header = headers.get("authorization", headers.get("Authorization", "")) - _caller_key = ( - auth_header[7:] - if auth_header.startswith("Bearer ") - else headers.get("x-api-key", "") - ) + _caller_key = _caller_key_from_headers(headers) # Only forward it if it can actually authenticate the TARGET. if _caller_key and _caller_key_travels_to(litellm_model, _caller_key): kwargs["api_key"] = _caller_key @@ -1886,13 +1965,17 @@ async def send_openai_message( except Exception as e: error_message = format_exception_message(e) logger.error(f"LiteLLM OpenAI error: {error_message}") + error_message = client_message(e, error_message) # Map to OpenAI error format error_type = "api_error" status_code = 500 error_str = str(e).lower() - if "authentication" in error_str or "credentials" in error_str: + upstream = _upstream_client_error(e, _OPENAI_ERROR_TYPES) + if upstream is not None: + status_code, error_type = upstream + elif "authentication" in error_str or "credentials" in error_str: error_type = "invalid_api_key" status_code = 401 elif "rate" in error_str or "limit" in error_str: @@ -1962,12 +2045,7 @@ async def stream_openai_message( # Forwarding x-api-key (e.g. sk-ant-dummy) would override their credentials. _env_auth_providers = ("bedrock", "vertex_ai", "vertex_ai_beta", "sagemaker") if self.provider not in _env_auth_providers: - auth_header = headers.get("authorization", headers.get("Authorization", "")) - _caller_key = ( - auth_header[7:] - if auth_header.startswith("Bearer ") - else headers.get("x-api-key", "") - ) + _caller_key = _caller_key_from_headers(headers) # Only forward it if it can actually authenticate the TARGET. if _caller_key and _caller_key_travels_to(litellm_model, _caller_key): kwargs["api_key"] = _caller_key @@ -1979,6 +2057,15 @@ async def stream_openai_message( async for chunk in response: chunk_dict = chunk.model_dump(exclude_none=True, exclude_unset=True) + # Report the model the client requested, not the LiteLLM-mapped + # provider slug (e.g. "openrouter/qwen3", + # "bedrock/us.anthropic.claude-..."). send_openai_message already + # rewrites the model to original_model on the non-streaming path; + # without this the streaming and non-streaming responses disagree + # and OpenAI clients that key cost/telemetry on the model field + # see an unrecognized name for every streamed request. + if "model" in chunk_dict: + chunk_dict["model"] = original_model yield f"data: {json.dumps(chunk_dict)}\n\n" yield "data: [DONE]\n\n" @@ -1988,7 +2075,7 @@ async def stream_openai_message( logger.error(f"LiteLLM OpenAI streaming error: {error_message}") error_data = { "error": { - "message": error_message, + "message": client_message(e, error_message), "type": "api_error", "code": "backend_error", } diff --git a/headroom/binaries.py b/headroom/binaries.py index 4916e8f227..552b1638ea 100644 --- a/headroom/binaries.py +++ b/headroom/binaries.py @@ -48,9 +48,13 @@ import zipfile from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import ssl from headroom._subprocess import run +from headroom.offline import guard_egress logger = logging.getLogger(__name__) @@ -263,9 +267,33 @@ def _mirror_url(url: str) -> str: # ---------- Download + verify --------------------------------------------- # +def _ssl_context() -> ssl.SSLContext | None: + """Return Headroom's configured SSL context for urllib.request.urlopen, or + ``None`` (urlopen's default) when the trust helper cannot be loaded. + + This ensures binary downloads from GitHub releases (and any configured + mirror) trust the same roots as the proxy — including corporate + TLS-inspection roots installed in the OS store and bundles referenced via + ``HEADROOM_CA_BUNDLE`` / ``SSL_CERT_FILE``. Without this, ``uv tool`` + installs (where certifi may not yet be wired to the stdlib path) raise + ``CERTIFICATE_VERIFY_FAILED`` even though certifi is already a dependency. + """ + try: + from headroom.proxy.ssl_context import build_urlopen_context + except ImportError: + return None + + return build_urlopen_context() + + def _download(url: str, dest: Path, *, progress: bool = True) -> None: if os.environ.get("HEADROOM_BINARIES_OFFLINE"): raise OfflineError(f"offline mode (HEADROOM_BINARIES_OFFLINE=1) but fetch required: {url}") + # HEADROOM_BINARIES_OFFLINE above is the narrow, binaries-only switch; this + # is the deployment-wide one. An operator who set the air-gap switch should + # not have to also discover a second, differently-named flag to stop + # Headroom fetching release assets from GitHub at install time. + guard_egress("release binary download", url) if not _has_writable_existing_parent(dest.parent): raise OSError(f"binary cache directory parent is not writable: {dest.parent}") dest.parent.mkdir(parents=True, exist_ok=True) @@ -275,10 +303,18 @@ def _download(url: str, dest: Path, *, progress: bool = True) -> None: 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: + ctx = _ssl_context() + except Exception as e: + raise BinaryFetchError(f"failed to configure TLS trust for binary download: {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) + if ctx is not None: + resp = urllib.request.urlopen(req, timeout=60, context=ctx) # noqa: S310 (https) + else: + resp = urllib.request.urlopen(req, timeout=60) # noqa: S310 (https) + with resp: total = int(resp.headers.get("Content-Length") or 0) _stream_to(resp, dest, total, label=dest.name, show_progress=progress) return diff --git a/headroom/cache/backends/sqlite.py b/headroom/cache/backends/sqlite.py index 764b138ee5..13d5386f1d 100644 --- a/headroom/cache/backends/sqlite.py +++ b/headroom/cache/backends/sqlite.py @@ -22,13 +22,14 @@ import logging import os import sqlite3 -import stat import threading import time from dataclasses import asdict, fields from pathlib import Path from typing import TYPE_CHECKING, Any +from ...fileperms import ensure_private_file + if TYPE_CHECKING: from ..compression_store import CompressionEntry @@ -90,65 +91,13 @@ def _ensure_private(path: Path) -> None: The originals stored here can contain sensitive tool output (file contents, command output). ``mkdir`` created the parent at the umask - default (typically world-traversable ``0o755``), and sqlite would - otherwise create the db file itself at the umask default too — leaving it - world/group readable. This creates the file with an explicit ``0o600`` - mode before ``sqlite3.connect`` runs (a *new* db is private from birth; - sqlite treats the empty file as a fresh database), or narrows a - pre-existing db. - - A store of raw tool output must not be opened world-readable, so a - failure to create or narrow it privately **raises** rather than silently - proceeding to open a wide file. - - The existing-file path is symlink-race resistant. ``O_EXCL`` on the - create refuses to reuse an attacker-planted file or symlink. When the - file already exists it is re-opened with ``O_NOFOLLOW`` and narrowed - through that descriptor (``fstat`` to confirm a regular file, ``fchmod`` - to set the mode) rather than by re-resolving the path: a symlink is - refused at the ``open`` syscall, and operating on the verified - descriptor closes the check-then-chmod TOCTOU that a ``chmod(path)`` - (which follows symlinks) would leave open. On Windows POSIX permission - bits and ``O_NOFOLLOW`` do not apply, so there is nothing to narrow. + default and sqlite would create the db at the umask default too, so the + file is created (or an existing one narrowed) through + :func:`headroom.fileperms.ensure_private_file`, which is symlink-race + resistant and raises :class:`PermissionError` rather than opening a + wide file. See that function for the exact guarantee per platform. """ - # Fast path: create the file ourselves. O_EXCL (plus O_NOFOLLOW where - # available) refuses to reuse an existing file or follow a planted - # symlink, so a brand-new db is private from birth at 0o600. - create_flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY - if hasattr(os, "O_NOFOLLOW"): - create_flags |= os.O_NOFOLLOW - try: - os.close(os.open(path, create_flags, 0o600)) - return - except FileExistsError: - pass - - # The file already exists. Without POSIX no-follow/fd primitives - # (Windows) permission bits do not apply and there is no symlink-race - # hardening to perform; sqlite opens by path as before. - if not (hasattr(os, "O_NOFOLLOW") and hasattr(os, "fchmod")): - return - - # Narrow the existing file, but only after proving — through a single - # no-follow descriptor — that it is a real regular file. A symlink is - # refused by O_NOFOLLOW (fail closed), and chmod-ing the descriptor - # rather than the path means an attacker cannot swap the inode we - # verified between the check and the narrow. - try: - fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) - except OSError as exc: - raise PermissionError( - f"refusing to open CCR store at {path}: not a regular file " - f"(symlink or open error: {exc})" - ) from exc - try: - if not stat.S_ISREG(os.fstat(fd).st_mode): - raise PermissionError(f"refusing to open CCR store at {path}: not a regular file") - # os.fchmod is POSIX-only (guarded by the hasattr check above); the - # ignore keeps type-checking clean on Windows where it is absent. - os.fchmod(fd, 0o600) # type: ignore[attr-defined] - finally: - os.close(fd) + ensure_private_file(path, what="CCR store") def _open(self) -> sqlite3.Connection: self._ensure_private(self._path) @@ -221,8 +170,18 @@ def _entry_from_json(self, raw: str) -> CompressionEntry | None: data = json.loads(raw) except (json.JSONDecodeError, TypeError): return None + if not isinstance(data, dict): + return None known = {f.name for f in fields(CompressionEntry)} - return CompressionEntry(**{k: v for k, v in data.items() if k in known}) + try: + return CompressionEntry(**{k: v for k, v in data.items() if k in known}) + except (TypeError, ValueError): + # A blob that parses as JSON but is missing a required field (schema + # drift across an upgrade, a partially written row) must degrade to a + # miss, not raise. Otherwise a single bad row crashes get() — and, + # via items(), _clean_expired() runs it on every store's eviction, so + # one poison row would break all reads, evictions, and stores. + return None def _purge_expired(self, now: float) -> int: """Delete expired rows using metadata; caller holds ``_lock``.""" diff --git a/headroom/cache/compression_feedback.py b/headroom/cache/compression_feedback.py index dbb8d5bd88..9032d3886f 100644 --- a/headroom/cache/compression_feedback.py +++ b/headroom/cache/compression_feedback.py @@ -344,13 +344,6 @@ def record_retrieval( # Extract potential field names from query self._extract_field_hints(pattern, event.query) - def _truncate_strategy_dicts(self, pattern: LocalToolPattern) -> None: - """Truncate strategy counters using the shared strategy outcome domain.""" - outcomes = pattern.strategy_outcomes - outcomes.prune() - pattern.strategy_compressions = outcomes.compressions - pattern.strategy_retrievals = outcomes.retrievals - def _extract_field_hints(self, pattern: LocalToolPattern, query: str) -> None: """Extract potential field names from search queries. diff --git a/headroom/cache/compression_store.py b/headroom/cache/compression_store.py index 1712fa007f..2611c53460 100644 --- a/headroom/cache/compression_store.py +++ b/headroom/cache/compression_store.py @@ -1114,11 +1114,24 @@ def _create_default_ccr_backend() -> CompressionStoreBackend | None: "memory" opts back into the in-process dict. Other values load adapters via setuptools entry point 'headroom.ccr_backend'. Returns None to use InMemoryBackend. + + Stateless mode (``--stateless`` / ``HEADROOM_STATELESS``) never opens the + SQLite file: the entries it would hold are the verbatim tool outputs that + stateless deployments run stateless to keep off disk. The default and an + explicit ``sqlite`` both fall back to the in-process store (retrieval then + does not survive a restart or cross workers, which is the documented + stateless trade-off). A named entry-point backend is still honoured — the + operator chose it explicitly, and it is how a stateless multi-worker + deployment gets a non-file (e.g. redis) store. """ + from ..paths import persistence_allowed + backend_type = (os.environ.get("HEADROOM_CCR_BACKEND") or "").strip().lower() if backend_type == "memory": return None if not backend_type or backend_type == "sqlite": + if not persistence_allowed("CCR retrieval store (ccr_store.db)"): + return None try: from .backends.sqlite import SQLiteBackend @@ -1197,6 +1210,22 @@ def get_compression_store( return _compression_store +def detach_compression_store() -> None: + """Drop the global compression store without clearing it. + + The next :func:`get_compression_store` builds a fresh store from the + current settings. Unlike :func:`reset_compression_store`, the old store's + entries are left as they are: nothing is deleted from its backend, so a + SQLite file is neither written nor emptied. Used when the proxy switches to + stateless mode at runtime. The old backend is not closed, because code + that already holds the old store may still be using it. + """ + global _compression_store + + with _store_lock: + _compression_store = None + + def reset_compression_store() -> None: """Reset the global compression store. Mainly for testing.""" global _compression_store diff --git a/headroom/cache/prefix_tracker.py b/headroom/cache/prefix_tracker.py index c9907bb4c6..58ded7b05b 100644 --- a/headroom/cache/prefix_tracker.py +++ b/headroom/cache/prefix_tracker.py @@ -59,6 +59,10 @@ "bedrock": 300, } +# Keep tracker state through the same post-TTL confidence margin used by +# cold-prefix detection, but never below the configured session cleanup floor. +_CACHE_TTL_CLEANUP_MARGIN_SECONDS = 60 + @dataclass class PrefixFreezeConfig: @@ -367,6 +371,138 @@ def _classify_history_canonical( ) +_TRANSIENT_SYSTEM_LINEAGE_ENV = "HEADROOM_TRANSIENT_SYSTEM_LINEAGE" + + +def _transient_system_lineage_enabled() -> bool: + return os.environ.get(_TRANSIENT_SYSTEM_LINEAGE_ENV, "").strip().lower() not in ( + "0", + "false", + "no", + "off", + ) + + +def _extends_past_system_tail(current_messages: list[Any], previous_messages: list[Any]) -> bool: + """Whether ``current`` continues ``previous`` once its trailing system turn is set aside. + + Claude Code ends some requests with a ``role:"system"`` reminder and + replaces it on the next turn, so the recorded history is never a prefix of + the new one. Only that last message is excluded: everything before it + must survive unchanged and include real user/assistant history, so a + changed leading or historical system instruction still diverges, and the + new history must be longer, so a sibling that only swaps the final + instruction is not a continuation. + """ + if len(previous_messages) < 2 or len(current_messages) <= len(previous_messages): + return False + tail = previous_messages[-1] + if not isinstance(tail, dict) or tail.get("role") != "system": + return False + stable = previous_messages[:-1] + return current_messages[: len(stable)] == stable and any( + isinstance(message, dict) and message.get("role") in ("user", "assistant") + for message in stable + ) + + +def _replaced_system_tail_delta( + current_messages: list[dict[str, Any]], + previous_original_messages: list[dict[str, Any]], + previous_forwarded_messages: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]] | None: + """Cache-stable split for a request that replaced the previous trailing system turn. + + The handlers record the model's reply after the request it answered, so the + replaced reminder is the last recorded message or sits right before that + reply. It is left out of the replayed prefix (it is not in this request); + every other recorded message must match ``current`` position for position. + """ + if not _transient_system_lineage_enabled() or len(previous_forwarded_messages) != len( + previous_original_messages + ): + return None + current = _lineage_snapshot(_canonicalize_for_prefix_compare(current_messages)) + previous = _lineage_snapshot(_canonicalize_for_prefix_compare(previous_original_messages)) + if len(current) != len(current_messages) or len(previous) != len(previous_original_messages): + # The projection dropped a directive-only message; raw indices would shift. + return None + for request_len in (len(previous), len(previous) - 1): + reply = previous[request_len:] + if reply and not (isinstance(reply[0], dict) and reply[0].get("role") == "assistant"): + continue + reminder = request_len - 1 + if ( + _extends_past_system_tail(current, previous[:request_len]) + and current[reminder : reminder + len(reply)] == reply + ): + return ( + copy.deepcopy( + previous_forwarded_messages[:reminder] + + previous_forwarded_messages[request_len:] + ), + copy.deepcopy(current_messages[reminder + len(reply) :]), + ) + return None + + +def _block_identity(block: Any) -> tuple[Any, ...]: + """What identifies a reply block across a client's own re-serialization.""" + if not isinstance(block, dict): + return ("raw", block) + kind = block.get("type") + if kind in ("tool_use", "server_tool_use"): + return (kind, block.get("id"), block.get("name")) + return (kind,) + + +def _client_rewrote_reply_delta( + current_messages: list[dict[str, Any]], + previous_original_messages: list[dict[str, Any]], + previous_forwarded_messages: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]] | None: + """Cache-stable split when the client re-serialized the model's last reply. + + The handlers record the model's raw reply as the last message of the previous + turn, but a client may send that reply back edited: Claude Code strips a leading + ``cd &&`` from Bash commands and drops the ``caller`` annotation. Every + earlier message still matches, and the reply itself was model output, never part + of a cached prefix, so the forwarded history up to the reply is still exactly what + the provider cached. Replay it, take the client's version of the reply, and + treat what follows as the delta. Falling back to raw forwarding here instead + replaces every earlier compressed message with its original and re-writes the + whole conversation to cache. + + The reply must be the same turn: an assistant message with the same block kinds + in the same order and the same tool_use ids. + """ + n = len(previous_original_messages) + if n < 2 or len(previous_forwarded_messages) != n or len(current_messages) < n: + return None + prev_reply, cur_reply = previous_original_messages[-1], current_messages[n - 1] + if not ( + isinstance(prev_reply, dict) + and isinstance(cur_reply, dict) + and prev_reply.get("role") == "assistant" + and cur_reply.get("role") == "assistant" + ): + return None + if _canonicalize_for_prefix_compare( + current_messages[: n - 1] + ) != _canonicalize_for_prefix_compare(previous_original_messages[: n - 1]): + return None + prev_blocks = _canonicalize_for_prefix_compare(prev_reply).get("content") + cur_blocks = _canonicalize_for_prefix_compare(cur_reply).get("content") + if not isinstance(prev_blocks, list) or not isinstance(cur_blocks, list): + return None + if [_block_identity(b) for b in prev_blocks] != [_block_identity(b) for b in cur_blocks]: + return None + return ( + copy.deepcopy(previous_forwarded_messages[: n - 1] + [cur_reply]), + copy.deepcopy(current_messages[n:]), + ) + + def classify_history_relation( current_messages: list[dict[str, Any]], previous_messages: list[dict[str, Any]], @@ -427,10 +563,20 @@ def extract_cache_stable_delta( This is a COMPARISON + slice only: the returned prefix is the previously-forwarded bytes verbatim and the delta is the raw appended messages — never a rebuild from the canonical projection — so the projection dropping non-semantic fields is safe. + + A previous request that ended in a ``role:"system"`` reminder the client has since + replaced also qualifies: its forwarded bytes are replayed without that reminder + (``HEADROOM_TRANSIENT_SYSTEM_LINEAGE=0`` disables this). """ if not previous_original_messages or previous_forwarded_messages is None: return None relation = classify_history_relation(current_messages, previous_original_messages) + if relation.kind == RELATION_DIVERGED: + return _replaced_system_tail_delta( + current_messages, previous_original_messages, previous_forwarded_messages + ) or _client_rewrote_reply_delta( + current_messages, previous_original_messages, previous_forwarded_messages + ) if relation.kind not in (RELATION_EXACT, RELATION_MESSAGE_APPEND): # A same-message block append needs a block-level splice in # ``overlay_cached_prefix``; slicing only whole messages would silently @@ -443,6 +589,36 @@ def extract_cache_stable_delta( ) +def restore_frozen_prefix( + original_messages: list[dict[str, Any]], + candidate_messages: list[dict[str, Any]], + *, + frozen_message_count: int, +) -> tuple[list[dict[str, Any]], int]: + """Force the frozen prefix of ``candidate_messages`` to match the original bytes. + + Returns ``(restored_messages, restored_count)``. Messages a transform dropped + from the prefix are put back; any frozen message it changed is replaced by + the original. ``restored_count`` is how many prefix messages were restored. + """ + if frozen_message_count <= 0 or not original_messages: + return candidate_messages, 0 + + frozen = min(frozen_message_count, len(original_messages)) + restored = list(candidate_messages) + + # Defensive: if a transform dropped prefix messages, restore them. + if len(restored) < frozen: + return list(original_messages[:frozen]) + restored, frozen + + changed = 0 + for idx in range(frozen): + if restored[idx] != original_messages[idx]: + restored[idx] = original_messages[idx] + changed += 1 + return restored, changed + + def overlay_cached_prefix( optimized_messages: list[dict[str, Any]], current_original_messages: list[dict[str, Any]], @@ -870,6 +1046,77 @@ def _place( return out if changed else messages +def mirror_client_message_cache_control( + messages: list[dict[str, Any]], + client_messages: list[dict[str, Any]] | None, +) -> list[dict[str, Any]]: + """Keep ``cache_control`` outside content blocks exactly where the client put it. + + OpenAI-compatible clients that talk to Anthropic models through a gateway + (OpenCode via ``@ai-sdk/openai-compatible``, then LiteLLM) do not mark + content blocks. They mark the MESSAGE dict — ``{"role": "tool", "content": + "...", "cache_control": {...}}`` — and, on an assistant turn, the entries of + its ``tool_calls`` list, which the gateway turns into ``tool_use`` blocks. + ``normalize_message_cache_control`` only sees block markers + (``_client_marker_positions`` reads list content), so after + ``overlay_cached_prefix`` / ``finalize_turn`` replay an earlier turn's + forwarded messages, these markers ride along untouched. A client that marks + three places per request (system, the newest tool call, the newest tool + result) ends up forwarding four, then five and more, and the gateway's + translation to Anthropic blocks crosses the limit of four (``A maximum of 4 + blocks with cache_control may be provided. Found 6``). + + The client's current request is the authority for these markers, the same + rule the block-level normalizer applies: on each message and on each of its + ``tool_calls``, drop the marker unless the client put one there in this + request, in which case copy the client's own value. Content — and any + block-level marker in it — is left alone, so the replayed bytes the provider + cached stay byte-identical. + + Index alignment is the invariant (the pipeline preserves message count and + each message's tool calls); where it does not hold, that message or list is + returned unchanged rather than guessing, and the breakpoint budget guard + still bounds the total. + """ + if not isinstance(client_messages, list) or len(client_messages) != len(messages): + return messages + changed = False + out: list[dict[str, Any]] = [] + for msg, client in zip(messages, client_messages): + if not isinstance(msg, dict): + out.append(msg) + continue + new_msg, msg_changed = _mirror_marker(msg, client) + calls = new_msg.get("tool_calls") + client_calls = client.get("tool_calls") if isinstance(client, dict) else None + if ( + isinstance(calls, list) + and isinstance(client_calls, list) + and len(calls) == len(client_calls) + ): + mirrored = [_mirror_marker(c, cc) for c, cc in zip(calls, client_calls)] + if any(c for _, c in mirrored): + new_msg = {**new_msg, "tool_calls": [c for c, _ in mirrored]} + msg_changed = True + out.append(new_msg) + changed = changed or msg_changed + return out if changed else messages + + +def _mirror_marker(holder: Any, client_holder: Any) -> tuple[Any, bool]: + """Give ``holder`` the client's own ``cache_control`` (or none); report change.""" + if not isinstance(holder, dict): + return holder, False + client_marker = client_holder.get("cache_control") if isinstance(client_holder, dict) else None + if isinstance(client_marker, dict): + if holder.get("cache_control") == client_marker: + return holder, False + return {**holder, "cache_control": dict(client_marker)}, True + if "cache_control" in holder: + return {k: v for k, v in holder.items() if k != "cache_control"}, True + return holder, False + + class PrefixCacheTracker: """Tracks provider prefix cache state across turns in a session. @@ -892,6 +1139,7 @@ class PrefixCacheTracker: def __init__(self, provider: str, config: PrefixFreezeConfig | None = None): self.provider = provider self.config = config or PrefixFreezeConfig() + self._cache_ttl_seconds: int | None = None self._cached_token_count: int = 0 self._cached_message_count: int = 0 self._turn_number: int = 0 @@ -1158,18 +1406,34 @@ def should_force_compress( @property def is_expired(self) -> bool: - """Check if this tracker has been idle beyond TTL.""" - return (time.time() - self._last_activity) > self.config.session_ttl_seconds + """Check if this tracker has been idle beyond its effective retention.""" + return self.is_expired_for_cache_ttl(None) + + def is_expired_for_cache_ttl(self, cache_ttl_seconds: int | None) -> bool: + """Check expiry including the longest configured or observed cache lifetime.""" + observed_ttl = max( + self._cache_ttl_seconds or 0, + self.config.cache_ttl_seconds or 0, + cache_ttl_seconds or 0, + ) + retention = max( + self.config.session_ttl_seconds, + observed_ttl + _CACHE_TTL_CLEANUP_MARGIN_SECONDS if observed_ttl else 0, + ) + return (time.time() - self._last_activity) > retention + + def observe_cache_ttl(self, cache_ttl_seconds: int | None) -> None: + """Remember the longest provider cache lifetime observed for this lineage.""" + if cache_ttl_seconds is not None: + self._cache_ttl_seconds = max(self._cache_ttl_seconds or 0, cache_ttl_seconds) def seconds_since_activity(self) -> float: """Wall-clock seconds since this tracker last saw activity. #856 P3b feeds this to the net-cost gate as an idle signal: as it - approaches the provider's prompt-cache TTL (~300s for Anthropic), - P_alive decays toward 0 and deep edits near cache lapse become free. - Distinct from :attr:`is_expired`, which uses the much longer - session-tracker *cleanup* TTL (``session_ttl_seconds``), not the cache - TTL. + approaches the provider's prompt-cache TTL, P_alive decays toward 0. + Unlike provider cache freshness, tracker cleanup uses the configured + session floor or the observed cache lifetime plus its cleanup margin. Wiring caveat: ``SessionTrackerStore.get_or_create`` refreshes ``_last_activity`` on access, so a caller that wants the idle gap @@ -1322,30 +1586,47 @@ def peek(self, session_id: str) -> PrefixCacheTracker | None: return None return tracker - def get_or_create(self, session_id: str, provider: str) -> PrefixCacheTracker: + def get_or_create( + self, session_id: str, provider: str, cache_ttl_seconds: int | None = None + ) -> PrefixCacheTracker: """Get existing tracker or create a new one for this session.""" + tracker = self._trackers.get(session_id) + if tracker is not None and tracker.is_expired: + self._discard_tracker(session_id) + tracker = None + if tracker is not None: + # Extend retention before the request-time cleanup sweep. + tracker.observe_cache_ttl(cache_ttl_seconds) self._maybe_cleanup() - if session_id in self._trackers: - tracker = self._trackers[session_id] - # Snapshot idle-since-last-response BEFORE bumping the access clock, - # so the net-cost/TTL gate sees the true gap (see the attribute's - # docstring in PrefixCacheTracker.__init__). + if tracker is not None and session_id in self._trackers: + # Snapshot idle-since-last-response BEFORE bumping the access clock. tracker._idle_seconds_at_fetch = max(0.0, time.time() - tracker._last_activity) tracker._last_activity = time.time() return tracker tracker = PrefixCacheTracker(provider, self._default_config) - tracker._idle_seconds_at_fetch = 0.0 # cold start: nothing cached to lapse + tracker.observe_cache_ttl(cache_ttl_seconds) + tracker._idle_seconds_at_fetch = 0.0 self._trackers[session_id] = tracker return tracker + def _discard_tracker(self, tracker_key: str) -> None: + """Remove a tracker and any lineage indexes that point to it.""" + self._trackers.pop(tracker_key, None) + for session_id, family in list(self._lineages.items()): + family.pop(tracker_key, None) + self._lineage_affinities.pop(tracker_key, None) + if not family: + del self._lineages[session_id] + def resolve_tracker( self, session_id: str, provider: str, messages: list[dict[str, Any]] | None = None, cache_affinity: str | None = None, + cache_ttl_seconds: int | None = None, ) -> PrefixCacheTracker: """Resolve the tracker for THIS conversation within a session id (#2085). @@ -1362,7 +1643,10 @@ def resolve_tracker( where a large leading run and two-block identity suffix survive while the middle tail is regenerated; all other rewrites start a fresh lineage. This keeps #2671's stable cache boundary attached without - merging unrelated parallel sub-calls. + merging unrelated parallel sub-calls. When nothing else matches, an + Anthropic chain whose trailing ``role:"system"`` reminder the client + replaced is matched on the history before that reminder + (``HEADROOM_TRANSIENT_SYSTEM_LINEAGE=0`` disables this). Byte-identical histories (templated fan-outs before they diverge) intentionally share a tracker: their provider cache line is identical too, so sharing is harmless. @@ -1384,20 +1668,19 @@ def resolve_tracker( cache_affinity: Stable fingerprint of the provider's non-message cache-key segments (model/tools/tool choice/thinking). Lineages with different affinity never share a tracker. + cache_ttl_seconds: Resolved provider cache lifetime for this + request. It extends this lineage's cleanup retention before + the periodic sweep. Returns: The ``PrefixCacheTracker`` for this conversation's lineage. """ if not messages or not self._default_config.enabled: - # No lineage signal, or prefix freeze is disabled (there is no - # frozen state to protect): legacy one-tracker-per-session-id. - return self.get_or_create(session_id, provider) - - # Prune expired trackers BEFORE matching, so a dead lineage cannot win - # the match. This also arms the cleanup interval: the get_or_create - # calls below cannot re-trigger a prune mid-function, so the family - # read here stays attached through the stamp at the end. - self._maybe_cleanup() + # No lineage signal, or freeze disabled: use the session tracker. + return self.get_or_create(session_id, provider, cache_ttl_seconds) + + # Defer the sweep until the request has matched its lineage, allowing + # that tracker to record the provider TTL before cleanup evaluates it. # The repo's canonical cross-turn equivalence, shared with the # cache-stable delta path: a moved cache breakpoint, string<->block @@ -1409,7 +1692,7 @@ def resolve_tracker( if not canon: # Degenerate: every message projected away (pure directive # content) — no lineage signal to match on. - return self.get_or_create(session_id, provider) + return self.get_or_create(session_id, provider, cache_ttl_seconds) snap = _lineage_snapshot(canon) family = self._lineages.setdefault(session_id, OrderedDict()) @@ -1418,6 +1701,13 @@ def resolve_tracker( # Rewritten-tail matches are deliberately last and require a unique best # structural score; ambiguity starts a fresh lineage instead of making # sibling sub-calls ping-pong one tracker. + expired_keys = [ + key for key in family if key not in self._trackers or self._trackers[key].is_expired + ] + for key in expired_keys: + family.pop(key, None) + self._lineage_affinities.pop(key, None) + self._trackers.pop(key, None) by_length = sorted(family.items(), key=lambda item: len(item[1]), reverse=True) best_key: str | None = None for accepted in ( @@ -1456,6 +1746,28 @@ def resolve_tracker( len(rewrite_candidates) == 1 or rewrite_candidates[0][0] != rewrite_candidates[1][0] ): best_key = rewrite_candidates[0][1] + elif ( + not rewrite_candidates + and provider == "anthropic" + and _transient_system_lineage_enabled() + ): + # Nothing matched as recorded. A chain whose only change is + # its replaced trailing system reminder is still this + # conversation; without it every such turn starts a cold + # tracker, the confirmed prefix is not replayed, and any + # recompressed history re-writes the provider cache. Only a + # unique longest chain qualifies, so equal-length siblings + # that differ in their reminder stay separate. + tail_candidates = [ + (len(chain), key) + for key, chain in by_length + if self._lineage_affinities.get(key) == cache_affinity + and _extends_past_system_tail(snap, chain) + ] + if tail_candidates and ( + len(tail_candidates) == 1 or tail_candidates[0][0] != tail_candidates[1][0] + ): + best_key = tail_candidates[0][1] if best_key is None: cap = self._default_config.max_lineages_per_session @@ -1479,7 +1791,7 @@ def resolve_tracker( cap, session_id, ) - return self.get_or_create(overflow_key, provider) + return self.get_or_create(overflow_key, provider, cache_ttl_seconds) if not family: # First lineage rides the bare session id; this also adopts a # tracker created earlier via plain get_or_create. @@ -1487,11 +1799,13 @@ def resolve_tracker( else: best_key = f"{session_id}\x00{next(self._lineage_counter)}" - # get_or_create (not a private fetch) so test stubs that patch the - # instance method keep intercepting tracker creation; its internal - # cleanup is interval-gated and was armed above, so it cannot prune - # the family before the stamp below. - tracker = self.get_or_create(best_key, provider) + # Fetch through the public method so test stubs can intercept tracker + # creation. The selected lineage's TTL is applied before its cleanup + # sweep, then its snapshot is stamped after the family has been pruned. + tracker = self.get_or_create(best_key, provider, cache_ttl_seconds) + # Lookup can evict an expired bare tracker and detach an empty family. + # Stamp the replacement into the live index, not that detached mapping. + family = self._lineages.setdefault(session_id, family) family[best_key] = snap self._lineage_affinities[best_key] = cache_affinity return tracker diff --git a/headroom/cache_economics.py b/headroom/cache_economics.py new file mode 100644 index 0000000000..76be1e40dd --- /dev/null +++ b/headroom/cache_economics.py @@ -0,0 +1,32 @@ +"""Lightweight provider cache fallbacks shared by all pricing surfaces. + +Published per-model catalog rates always take precedence. These ratios are +estimates for priced models whose catalog row has no cache rates. xAI uses +a 0.16 read ratio and no write premium; known model rates override it. +""" + +from typing import Any + +CACHE_ECONOMICS: dict[str, dict[str, Any]] = { + "anthropic": { + "read_multiplier": 0.1, + "write_multiplier": 1.25, + "label": "Explicit breakpoints, 5-min TTL", + }, + "openai": { + "read_multiplier": 0.5, + "write_multiplier": 1.0, + "label": "Automatic, no TTL control", + }, + "gemini": { + "read_multiplier": 0.1, + "write_multiplier": 1.0, + "label": "Explicit cachedContent, configurable TTL", + }, + "bedrock": { + "read_multiplier": 0.1, + "write_multiplier": 1.25, + "label": "Same as Anthropic (Bedrock)", + }, + "xai": {"read_multiplier": 0.16, "write_multiplier": 1.0, "label": "Automatic, no TTL control"}, +} diff --git a/headroom/ccr/batch_processor.py b/headroom/ccr/batch_processor.py index bfe73b3179..49abf4230a 100644 --- a/headroom/ccr/batch_processor.py +++ b/headroom/ccr/batch_processor.py @@ -23,7 +23,7 @@ import json import logging from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any import httpx @@ -34,19 +34,6 @@ logger = logging.getLogger(__name__) -class APIClient(Protocol): - """Protocol for making API calls.""" - - async def post( - self, - url: str, - headers: dict[str, str], - json: dict[str, Any], - ) -> httpx.Response: - """Make a POST request.""" - ... - - @dataclass class BatchResultProcessorConfig: """Configuration for batch result processing.""" diff --git a/headroom/ccr/batch_store.py b/headroom/ccr/batch_store.py index 4bee798977..a68779a99e 100644 --- a/headroom/ccr/batch_store.py +++ b/headroom/ccr/batch_store.py @@ -126,7 +126,6 @@ def __init__( self._ttl = ttl self._max_contexts = max_contexts self._lock = asyncio.Lock() - self._cleanup_task: asyncio.Task | None = None async def store(self, context: BatchContext) -> None: """Store a batch context. diff --git a/headroom/ccr/context_tracker.py b/headroom/ccr/context_tracker.py index 8859664075..786eaaf151 100644 --- a/headroom/ccr/context_tracker.py +++ b/headroom/ccr/context_tracker.py @@ -23,6 +23,7 @@ import logging import re import time +from collections.abc import Collection from dataclasses import dataclass from typing import Any @@ -232,6 +233,7 @@ def analyze_query( current_turn: int | None = None, *, workspace_key: str, + present_hashes: Collection[str] | None = None, ) -> list[ExpansionRecommendation]: """Analyze a query to find relevant compressed contexts. @@ -246,6 +248,13 @@ def analyze_query( a workspace before invoking; the empty string short- circuits to an empty result set rather than matching empty-keyed test contexts to avoid accidental crossover. + present_hashes: CCR hashes whose markers appear in the request + being answered. When given, only those contexts may expand. + One workspace can serve several live conversations at once + (a Claude Code lead and its teammates share a cwd), and a + compression the requesting conversation never received is + another conversation's tool output, not context it lost + (#1174). ``None`` skips this filter. Returns: List of expansion recommendations, sorted by relevance. @@ -278,6 +287,8 @@ def analyze_query( # the current request resolved to. if context.workspace_key != workspace_key: continue + if present_hashes is not None and hash_key not in present_hashes: + continue # Check age age = now - context.timestamp diff --git a/headroom/ccr/mcp_server.py b/headroom/ccr/mcp_server.py index f013e092ee..de0e0e0451 100644 --- a/headroom/ccr/mcp_server.py +++ b/headroom/ccr/mcp_server.py @@ -209,7 +209,13 @@ def _format_session_summary( def _append_shared_event(event: dict[str, Any]) -> None: - """Append an event to the shared stats file (cross-process, file-locked).""" + """Append an event to the shared stats file (cross-process, file-locked). + + No-op in stateless mode (``HEADROOM_STATELESS``): the aggregate then covers + only this process, which is the documented stateless trade-off. + """ + if not _paths.persistence_allowed("MCP session stats"): + return try: SHARED_STATS_DIR.mkdir(parents=True, exist_ok=True) event["pid"] = os.getpid() @@ -249,8 +255,9 @@ def _read_shared_events(window_seconds: int = SESSION_WINDOW_SECONDS) -> list[di keep_lines.append(line + "\n") except json.JSONDecodeError: continue - # Prune old entries (only if we dropped some) - if len(keep_lines) < len(lines): + # Prune old entries (only if we dropped some). Never rewrite the file in + # stateless mode — reading is fine, writing is not. + if len(keep_lines) < len(lines) and _paths.persistence_allowed("MCP session stats"): try: with open(SHARED_STATS_FILE, "w") as f: if _HAS_FCNTL: @@ -381,7 +388,6 @@ def __init__( self._stats = SessionStats() self._local_store: Any = None # Lazy-initialized CompressionStore self._token_estimator: EstimatingTokenCounter | None = None - self._compressor_initialized = False # File read cache: path → (content_hash, ccr_hash, line_count, token_count) self._file_cache: dict[str, tuple[str, str, int, int]] = {} diff --git a/headroom/ccr/response_handler.py b/headroom/ccr/response_handler.py index 417141dcdc..f9bcab1431 100644 --- a/headroom/ccr/response_handler.py +++ b/headroom/ccr/response_handler.py @@ -24,8 +24,10 @@ from ..cache.compression_store import format_retrieval_miss_detail, get_compression_store from .tool_calls import ( CCRToolCall, + drop_tool_calls, extract_tool_calls, has_ccr_tool_calls, + is_ccr_tool_call, parse_ccr_tool_calls, ) from .tool_injection import CCR_TOOL_NAME @@ -476,18 +478,41 @@ async def handle_response( # No CCR tool calls, we're done break - # If the model called CCR alongside non-CCR tools, we cannot build - # a valid continuation — every tool_use in the assistant message - # requires a matching tool_result, but we only have CCR results. - # Skip CCR handling and let the client resolve all tool calls. + # The model called CCR alongside client tools. The client has no + # headroom_retrieve (Claude Code answers "No such tool available: + # headroom_retrieve", so the model never gets the content), and a + # continuation needs a tool_result for every tool_use. The client + # calls have not run yet, so drop them and serve the retrieval now; + # the model decides again, content in hand, in the continuation. + # That continuation replaces this turn whatever it does: re-issue + # the calls, change them or drop them. Only a failed continuation + # hands this turn back (below), since the model made no newer + # decision. A CCR-named call without a valid hash, or a provider + # where dropping a sibling is not safe (see drop_tool_calls), keeps + # the turn as it is for the client to resolve. + mixed_turn: dict[str, Any] | None = None if other_calls: - logger.warning( - "CCR: Skipping CCR handling — model called %d non-CCR tool(s) " - "alongside headroom_retrieve. Cannot create a valid continuation " - "without results for the other tools. Client must handle all tool calls.", + trimmed = ( + current_response + if any(is_ccr_tool_call(c) for c in other_calls) + else drop_tool_calls(current_response, provider, other_calls) + ) + if trimmed is current_response: + logger.warning( + "CCR: Skipping CCR handling — model called %d non-CCR tool(s) " + "alongside headroom_retrieve. Cannot create a valid continuation " + "without results for the other tools. Client must handle all tool calls.", + len(other_calls), + ) + break + logger.info( + "CCR: model called headroom_retrieve alongside %d client tool(s); " + "serving the retrieval and dropping the unrun client call(s) " + "for the model to re-issue", len(other_calls), ) - break + mixed_turn = current_response + current_response = trimmed rounds += 1 with self._retrieval_count_lock: @@ -540,7 +565,11 @@ async def handle_response( # entirely (#3129). logger.error("CCR: Continuation API call failed: %s: %r", type(e).__name__, e) # Return the response we had (with unhandled CCR calls) - # The client will see the tool_use and might handle it differently + # The client will see the tool_use and might handle it differently. + # For a mixed turn that is the model's own turn, client calls + # included (#839), never the trimmed one that lost them. + if mixed_turn is not None: + current_response = mixed_turn break if rounds >= self.config.max_retrieval_rounds: diff --git a/headroom/ccr/tool_calls.py b/headroom/ccr/tool_calls.py index 44d75ff344..8261e31e2c 100644 --- a/headroom/ccr/tool_calls.py +++ b/headroom/ccr/tool_calls.py @@ -70,6 +70,36 @@ def extract_tool_calls(response: dict[str, Any], provider: str) -> list[dict[str return [] +def drop_tool_calls( + response: dict[str, Any], + provider: str, + calls: list[dict[str, Any]], +) -> dict[str, Any]: + """Return a shallow copy of ``response`` without ``calls``. + + ``calls`` are objects ``extract_tool_calls`` returned for this response and + are matched by identity. Anthropic and OpenAI chat only: a Responses + ``function_call`` can be the item a ``reasoning`` item requires next, and a + Gemini ``functionCall`` part can carry the turn's thought signature, so + removing one there can make the continuation invalid. Other providers get + ``response`` back unchanged. + """ + drop = {id(call) for call in calls} + if provider == "anthropic": + content = response.get("content") + if isinstance(content, list): + return {**response, "content": [b for b in content if id(b) not in drop]} + elif provider == "openai": + choices = response.get("choices") + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + message = choices[0].get("message") + if isinstance(message, dict) and isinstance(message.get("tool_calls"), list): + tool_calls = [c for c in message["tool_calls"] if id(c) not in drop] + first = {**choices[0], "message": {**message, "tool_calls": tool_calls}} + return {**response, "choices": [first, *choices[1:]]} + return response + + def is_ccr_tool_call(tool_call: dict[str, Any]) -> bool: """Return true when a provider-native tool call names the CCR retrieval tool.""" return ( @@ -79,6 +109,25 @@ def is_ccr_tool_call(tool_call: dict[str, Any]) -> bool: ) +def has_ccr_retrieve_tool(tools: Any) -> bool: + """Return true when a tool list defines the CCR retrieval tool. + + Accepts flat definitions (Anthropic, OpenAI Responses: ``{"name": ...}``) and + chat-completions definitions nested under ``"function"``. + """ + if not isinstance(tools, list): + return False + for tool in tools: + if not isinstance(tool, dict): + continue + if tool.get("name") == CCR_TOOL_NAME: + return True + function = tool.get("function") + if isinstance(function, dict) and function.get("name") == CCR_TOOL_NAME: + return True + return False + + def has_ccr_tool_calls(response: dict[str, Any], provider: str) -> bool: """Return true when ``response`` contains at least one CCR tool call.""" return any(is_ccr_tool_call(tool_call) for tool_call in extract_tool_calls(response, provider)) diff --git a/headroom/ccr/tool_injection.py b/headroom/ccr/tool_injection.py index b4933610bd..68f4bbfca5 100644 --- a/headroom/ccr/tool_injection.py +++ b/headroom/ccr/tool_injection.py @@ -235,7 +235,9 @@ class CCRToolInjector: # # Standard format: [N compressed to M. Retrieve more: hash=xxx] # Matches items, lines, matches, or any other type - re.compile(r"\[(\d+) \w+ compressed to (\d+)\. Retrieve more: hash=([a-f0-9]{24})\]"), + re.compile( + r"\[(\d+) \w+ compressed to (\d+)(?: \([^)]+\))?\.(?: Original content preserved\.)? Retrieve more: hash=([a-f0-9]{24})\]" + ), # Legacy format without "to M" or "Retrieve more:" (old TextCompressor) re.compile(r"\[(\d+) \w+ compressed\. hash=([a-f0-9]{24})\]"), # Generic fallback: any bracket compression marker with hash (exactly 24 chars) diff --git a/headroom/cli.py b/headroom/cli.py deleted file mode 100644 index c7e8c47756..0000000000 --- a/headroom/cli.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Backwards compatibility - CLI moved to headroom.cli package.""" - -from headroom.cli import main - -if __name__ == "__main__": - main() diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index e30628661a..aa98ec6daa 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -25,6 +25,7 @@ from headroom._version import format_version_label, normalize_release_version from headroom.install.health import probe_json +from headroom.install.models import DeploymentManifest from headroom.install.paths import ( claude_settings_path, codex_config_path, @@ -53,6 +54,8 @@ FAIL = "fail" SKIP = "skip" +DEFAULT_PROXY_PORT = 8787 + _LOOPBACK_URL_RE = re.compile(r"https?://(?:127\.0\.0\.1|localhost):(\d+)") _CODEX_BASE_URL_RE = re.compile(r'(?m)^[ \t]*base_url\s*=\s*"([^"\r\n]+)"') _CODEX_MODEL_PROVIDER_RE = re.compile(r'(?m)^[ \t]*model_provider\s*=\s*"([^"\r\n]+)"') @@ -316,7 +319,7 @@ def check_version_drift(livez: dict[str, Any] | None, installed: str) -> CheckRe def _claude_base_url_in(path: Path) -> tuple[str, CheckResult | None]: - """Read ``env.ANTHROPIC_BASE_URL`` from one Claude settings file. + """Read the active Claude routing URL from one settings file. Returns ``(base_url, error)``. A parse problem comes back as a WARN so the caller surfaces it verbatim instead of skipping the file and reporting the @@ -340,7 +343,10 @@ def _claude_base_url_in(path: Path) -> tuple[str, CheckResult | None]: ) env_block = payload.get("env") if isinstance(env_block, dict): - return str(env_block.get("ANTHROPIC_BASE_URL", "") or ""), None + base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "") + if not base_url and str(env_block.get("CLAUDE_CODE_USE_FOUNDRY", "") or ""): + base_url = str(env_block.get("ANTHROPIC_FOUNDRY_BASE_URL", "") or "") + return base_url, None return "", None @@ -837,6 +843,28 @@ def run_network_probes(extra_urls: Sequence[str]) -> tuple[list[Any], set[str]]: return reports, required +def network_checks(extra_urls: Sequence[str]) -> list[CheckResult]: + """The ``--network`` rows, or one skipped row when ``HEADROOM_OFFLINE`` is set. + + Every probe here dials an internet host, so under the air-gap switch none + of them runs; saying so beats a page of "unreachable" failures. + """ + from headroom.offline import OFFLINE_ENV, OfflineEgressBlocked + + try: + reports, required = run_network_probes(extra_urls) + except OfflineEgressBlocked as blocked: + return [ + CheckResult( + name="network", + status=SKIP, + summary=f"skipped: {OFFLINE_ENV} is set, no endpoint was contacted", + hint=str(blocked), + ) + ] + return check_network_endpoints(reports, required) + + def check_shell_env(environ: Mapping[str, str], port: int) -> CheckResult: """Is the *current shell* pointed at the proxy for ad-hoc runs?""" name = "shell env" @@ -1096,14 +1124,53 @@ def _render(checks: list[CheckResult], port: int, installed: str) -> None: console.print("\n[green bold]all checks passed[/green bold]") +def resolve_probe_port( + requested: int | None, + manifests: Sequence[DeploymentManifest], +) -> int: + """Resolve the port `doctor` should probe. + + Precedence: an explicit ``--port`` (or ``HEADROOM_PORT``) wins, then the + port a managed deployment recorded, then the built-in default. + + The manifest tier matters because ``headroom deploy --port N`` writes N + into the deployment's own environment, not into the shell `doctor` later + runs in. Without it a healthy non-default deployment is reported as + unreachable, and the remediation hints point at the wrong port. + + A deployment is only consulted when it is unambiguous: the ``default`` + profile, or a lone profile under another name. With several named profiles + and no explicit request there is no non-arbitrary choice, so the built-in + default is used and the per-deployment section reports each one instead. + + Args: + requested: Port supplied on the command line or via the environment. + manifests: Deployment manifests to consider. + + Returns: + The port to probe. + """ + if requested is not None: + return requested + default_profile = next((m for m in manifests if m.profile == "default"), None) + if default_profile is not None: + return default_profile.port + if len(manifests) == 1: + return manifests[0].port + return DEFAULT_PROXY_PORT + + @main.command() @click.option( "--port", "-p", - default=8787, + default=None, type=click.IntRange(1, 65535), envvar="HEADROOM_PORT", - help="Proxy port to check (default: 8787, env: HEADROOM_PORT)", + help=( + "Proxy port to check (env: HEADROOM_PORT). Defaults to the port of a " + f"single managed deployment, else {DEFAULT_PROXY_PORT}." + ), ) @click.option("--json", "emit_json", is_flag=True, help="Emit JSON instead of formatted output.") @click.option( @@ -1122,7 +1189,12 @@ def _render(checks: list[CheckResult], port: int, installed: str) -> None: metavar="URL", help="Extra upstream URL to test with --network (repeatable), e.g. a custom gateway.", ) -def doctor(port: int, emit_json: bool, network: bool, network_urls: tuple[str, ...]) -> None: +def doctor( + port: int | None, + emit_json: bool, + network: bool, + network_urls: tuple[str, ...], +) -> None: """Check that the Headroom proxy and client routing are working. \b @@ -1131,13 +1203,14 @@ def doctor(port: int, emit_json: bool, network: bool, network_urls: tuple[str, . 1 warnings only (working, but not optimally wired) 2 at least one failure (proxy down / deployment down) """ + manifests = list_manifests() + port = resolve_probe_port(port, manifests) base_url = f"http://127.0.0.1:{port}" livez = probe_json(f"{base_url}/livez") readyz = probe_json(f"{base_url}/readyz", timeout=3.0) if livez else None health = probe_json(f"{base_url}/health", timeout=5.0) if livez else None stats = probe_json(f"{base_url}/stats", timeout=5.0) if livez else None installed = get_version() - manifests = list_manifests() live_elsewhere = None if livez is None: # Cheap, bounded: only ports Headroom recorded (HEADROOM_PORT, 8787, @@ -1182,8 +1255,7 @@ def doctor(port: int, emit_json: bool, network: bool, network_urls: tuple[str, . if proxy_env_check is not None: checks.append(proxy_env_check) if network or network_urls: - reports, required = run_network_probes(network_urls) - checks.extend(check_network_endpoints(reports, required)) + checks.extend(network_checks(network_urls)) auth_conflict_check = check_claude_auth_conflict( claude_settings_path(), project_claude_settings, diff --git a/headroom/cli/init.py b/headroom/cli/init.py index d623014fff..abf950be78 100644 --- a/headroom/cli/init.py +++ b/headroom/cli/init.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any +from headroom import fsutil from headroom._subprocess import run try: @@ -36,6 +37,7 @@ from headroom.install.paths import ( claude_settings_path, codex_config_path, + codex_home_dir, codex_hooks_path, codex_project_config_path, validate_profile_name, @@ -45,6 +47,7 @@ from headroom.install.runtime import ( acquire_runtime_start_lock, resolve_headroom_command, + runtime_ownership, runtime_status, start_detached_agent, start_persistent_docker, @@ -55,13 +58,18 @@ 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.install import ( + CodexAuthConfigError, + build_codex_auth_config, + codex_uses_chatgpt_auth, +) from headroom.providers.codex.threads import retag_to_headroom from .main import main logger = logging.getLogger(__name__) + _VERBOSE_HANDLER_ATTR = "_headroom_init_verbose_handler" _GLOBAL_PROFILE = "init-user" @@ -192,7 +200,7 @@ def _json_file(path: Path) -> dict[str, Any]: def _write_json(path: Path, payload: dict[str, Any]) -> None: logger.debug("write json: %s (keys=%s)", path, sorted(payload.keys())) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + fsutil.write_text(path, json.dumps(payload, indent=2) + "\n") def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None: @@ -308,10 +316,47 @@ def _remove_marker_block(content: str, marker_start: str, marker_end: str) -> st return content[:start].rstrip() + "\n\n" + content[end:].lstrip() -def _strip_codex_init_block(content: str) -> str: +def _strip_codex_root_routing_orphans(content: str) -> str: + """Drop Headroom loopback routing keys left at the document root. + + Only the root (everything before the first table header) is Headroom's to + clean: the same keys inside ``[profiles.*]`` tables are user-owned + per-profile overrides, even when they point at Headroom. + """ + import re + + first_table = re.search(r"(?m)^[ \t]*\[", content) + split = first_table.start() if first_table else len(content) + root, rest = content[:split], content[split:] + root = re.sub(r'(?m)^[ \t]*model_provider[ \t]*=[ \t]*"headroom"[ \t]*\r?\n', "", root) + root = re.sub( + r'(?m)^[ \t]*openai_base_url[ \t]*=[ \t]*"http://127\.0\.0\.1:\d+/v1"[ \t]*\r?\n', + "", + root, + ) + return root + rest + + +_CODEX_PROVIDER_SNAPSHOT_PREFIX = "# Headroom init previous provider: " + + +def _codex_init_provider_snapshot(content: str) -> str | None: + match = re.search(r"(?m)^" + re.escape(_CODEX_PROVIDER_SNAPSHOT_PREFIX) + r"([^\n]*)", content) + if match is None: + return None + snapshot = json.loads(match.group(1)) + if not isinstance(snapshot, str): + raise ValueError("Invalid Headroom init provider snapshot") + tomllib.loads(snapshot) + return snapshot + + +def _strip_codex_init_block(content: str, *, restore_provider: bool = True) -> str: """Remove all Headroom init-managed blocks and orphan keys from a Codex config.toml string.""" import re + provider_snapshot = _codex_init_provider_snapshot(content) + # Remove any provider marker → end marker span, possibly repeated. while _CODEX_PROVIDER_MARKER_START in content and _CODEX_PROVIDER_MARKER_END in content: start = content.index(_CODEX_PROVIDER_MARKER_START) @@ -327,12 +372,7 @@ def _strip_codex_init_block(content: str) -> str: # Strip any orphan top-level keys that a crashed or partial write may have # left outside the marker block. - content = re.sub(r'(?m)^[ \t]*model_provider[ \t]*=[ \t]*"headroom"[ \t]*\r?\n', "", content) - content = re.sub( - r'(?m)^[ \t]*openai_base_url[ \t]*=[ \t]*"http://127\.0\.0\.1:\d+/v1"[ \t]*\r?\n', - "", - content, - ) + content = _strip_codex_root_routing_orphans(content) # Strip any orphaned [model_providers.headroom] table that is recognisably ours. orphan_headroom_table = re.compile( @@ -341,6 +381,8 @@ def _strip_codex_init_block(content: str) -> str: r"(?=^\[|\Z)" ) content = orphan_headroom_table.sub("", content) + if restore_provider and provider_snapshot is not None: + content = content.rstrip() + "\n\n" + provider_snapshot return content.lstrip("\n").rstrip() + "\n" if content.strip() else "" @@ -350,26 +392,78 @@ def _ensure_codex_provider(path: Path, port: int) -> None: logger.debug("ensure codex provider block: %s (port=%s)", path, port) warn_codex_provider_port_change(path, port) + auth_path = codex_home_dir() / "auth.json" # Emit requires_openai_auth only for ChatGPT-OAuth users (restores the # account menu); omitting it for API-key users avoids forcing an OAuth # login (#406). requires_openai_auth = ( - "requires_openai_auth = true\n" - if codex_uses_chatgpt_auth(path.parent / "auth.json") - else "" + "requires_openai_auth = true\n" if codex_uses_chatgpt_auth(auth_path) else "" ) + try: + auth_config = build_codex_auth_config(auth_path, config_path=path) + except CodexAuthConfigError as exc: + raise click.ClickException(str(exc)) from exc block = ( f"{_CODEX_PROVIDER_MARKER_START}\n" 'model_provider = "headroom"\n' f'openai_base_url = "http://127.0.0.1:{port}/v1"\n\n' "[model_providers.headroom]\n" - 'name = "Headroom init proxy"\n' + # Codex derives remote-compaction support from the provider display + # name. Keep the proxy's provider id as ``headroom`` for routing, but + # use the built-in OpenAI name so the capability is preserved. + 'name = "OpenAI"\n' f'base_url = "http://127.0.0.1:{port}/v1"\n' "supports_websockets = true\n" + f"{auth_config}" f"{requires_openai_auth}" f"{_CODEX_PROVIDER_MARKER_END}" ) content = path.read_text(encoding="utf-8") if path.exists() else "" + # Adopt an existing unmarked provider table instead of declaring it twice. + # Keep user options that are not owned by the generated provider block. + provider_snapshot = _codex_init_provider_snapshot(content) + provider_table = re.search( + r"(?m)^[ \t]*\[model_providers\.headroom\][ \t]*(?:#[^\n]*)?\r?\n", content + ) + if provider_table: + next_table = re.search(r"(?m)^[ \t]*\[", content[provider_table.end() :]) + table_end = provider_table.end() + next_table.start() if next_table else len(content) + if provider_snapshot is None and _CODEX_PROVIDER_MARKER_START not in content: + provider_snapshot = content[provider_table.start() : table_end] + existing_options = content[provider_table.end() : table_end] + existing_options = re.sub( + r"(?m)^" + re.escape(_CODEX_PROVIDER_SNAPSHOT_PREFIX) + r"[^\n]*(?:\n|$)", + "", + existing_options, + ) + existing_options = existing_options.replace(_CODEX_PROVIDER_MARKER_END, "") + generated_keys = set(re.findall(r"(?m)^([A-Za-z_][A-Za-z_0-9]*)[ \t]*=", block)) + generated_keys.update( + {"auth", "env_key", "requires_openai_auth", "experimental_bearer_token"} + ) + for key in generated_keys: + existing_options = re.sub( + rf"(?m)^[ \t]*{re.escape(key)}[ \t]*=[^\n]*(?:\n|$)", + "", + existing_options, + ) + block = block.replace( + _CODEX_PROVIDER_MARKER_END, + existing_options + _CODEX_PROVIDER_MARKER_END, + ) + if _CODEX_PROVIDER_MARKER_START not in content: + content = content[: provider_table.start()] + content[table_end:] + content = _replace_marker_block( + content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, "", at_root=True + ) + if provider_snapshot is not None: + block = block.replace( + _CODEX_PROVIDER_MARKER_END, + _CODEX_PROVIDER_SNAPSHOT_PREFIX + + json.dumps(provider_snapshot) + + "\n" + + _CODEX_PROVIDER_MARKER_END, + ) # init owns the ROOT-level model_provider/openai_base_url: drop any prior # root assignment so we replace it instead of emitting a duplicate top-level # key (#260). Scope the strip to the document root (everything before the @@ -388,7 +482,8 @@ def _ensure_codex_provider(path: Path, port: int) -> None: content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, block, at_root=True ) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") + tomllib.loads(content) + fsutil.write_text(path, content) # Codex filters its history menu by the active model_provider, so existing # native threads vanish once we switch to "headroom". Retag them to match the # active provider so the history stays whole (#961), mirroring the install @@ -518,7 +613,7 @@ def _ensure_codex_feature_flag(path: Path) -> None: content.rstrip() + "\n\n[features]\n\n" + _codex_feature_block() + "\n" ).lstrip() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") + fsutil.write_text(path, content) def _ensure_codex_hooks(path: Path, profile: str) -> None: @@ -780,28 +875,45 @@ def _ensure_profile_running(profile: str) -> None: return if manifest is None: return + failure: str | None = None with _suppress_hook_output(): - if wait_ready(manifest, timeout_seconds=1): + if wait_ready(manifest, timeout_seconds=1, require_identity=True): return try: with acquire_runtime_start_lock(manifest.profile) as acquired: if not acquired: return - if wait_ready(manifest, timeout_seconds=1): + if wait_ready(manifest, timeout_seconds=1, require_identity=True): return if runtime_status(manifest) == "running": - if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS): + if wait_ready( + manifest, + timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS, + require_identity=True, + ): return stop_runtime(manifest) - if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + if runtime_ownership(manifest) == "docker-supervisor": start_persistent_docker(manifest) elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: start_supervisor(manifest) else: start_detached_agent(manifest.profile) - wait_ready(manifest, timeout_seconds=45) - except Exception: - return + if not wait_ready(manifest, timeout_seconds=45, require_identity=True): + failure = "it did not become ready within 45s" + except (click.ClickException, RuntimeError, OSError, subprocess.SubprocessError) as exc: + # Supervisor and runtime starts raise these; anything else is a bug + # and should surface. Report outside the suppressed block so the + # one line reaches stderr while stdout stays clean for the hook. + failure = str(exc) + if failure is not None: + logger.warning( + "headroom: could not start persistent proxy %r: %s. " + "Check it with `headroom install status --profile %s`.", + manifest.profile, + failure, + manifest.profile, + ) def _probe_init_targets(global_scope: bool) -> list[tuple[str, str | None]]: @@ -907,6 +1019,12 @@ def _init_codex(*, global_scope: bool, profile: str, port: int) -> None: click.echo( "Codex hooks are currently disabled upstream on Windows; provider routing was still installed." ) + click.echo( + "Nothing starts the Headroom proxy for Codex on Windows, so Codex cannot connect " + "while it is down. Use `headroom install apply` for a supervised proxy. To remove " + "this routing, run `headroom unwrap codex` (user scope) or delete the Headroom init " + "provider block from the project's .codex/config.toml." + ) click.echo("Restart Codex to activate Headroom configuration.") diff --git a/headroom/cli/install.py b/headroom/cli/install.py index e8bd77bb8a..870b53c243 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -9,6 +9,7 @@ from collections.abc import Mapping from copy import deepcopy from dataclasses import dataclass +from pathlib import Path import click from click.core import ParameterSource @@ -16,6 +17,7 @@ from headroom._subprocess import run from headroom.install.health import probe_json, probe_ready from headroom.install.models import ( + ArtifactRecord, ConfigScope, DeploymentManifest, InstallPreset, @@ -23,11 +25,13 @@ RuntimeKind, SupervisorKind, ) +from headroom.install.paths import opencode_config_path, recovery_manifest_path from headroom.install.planner import build_manifest, build_tool_envs from headroom.install.providers import apply_mutations, revert_mutations from headroom.install.runtime import ( acquire_runtime_start_lock, run_foreground, + runtime_ownership, runtime_status, start_detached_agent, start_persistent_docker, @@ -38,9 +42,12 @@ from headroom.install.state import ( ManifestError, delete_manifest, + delete_recovery_manifest, list_manifests, load_manifest, save_manifest, + save_manifest_strict, + save_recovery_manifest, ) from headroom.install.supervisors import ( install_supervisor, @@ -48,6 +55,7 @@ start_supervisor, stop_supervisor, ) +from headroom.providers.opencode.install import restore_opencode_backup from .main import main @@ -155,19 +163,27 @@ def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = _start_deployment(manifest, assume_start_lock=True) return - if probe_ready(manifest.health_url): - return - if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value and shutil.which("docker") is None: + docker_owned = runtime_ownership(manifest) == "docker-supervisor" + if docker_owned and shutil.which("docker") is None: raise click.ClickException( "Docker is required for this deployment but 'docker' was not found on PATH." ) - if runtime_status(manifest) == "running": - if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS): + status = runtime_status(manifest) + if status == "running" and probe_ready(manifest.health_url): + return + if status == "unknown": + raise click.ClickException( + f"Cannot start deployment '{manifest.profile}': runtime identity is unavailable." + ) + if status == "running": + if wait_ready( + manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS, require_identity=True + ): return stop_runtime(manifest) try: - if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + if runtime_ownership(manifest) == "docker-supervisor": start_persistent_docker(manifest) elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: start_supervisor(manifest) @@ -182,33 +198,54 @@ def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = f"({' '.join(map(str, e.cmd)) if isinstance(e.cmd, list | tuple) else e.cmd})" ) from None - if not wait_ready(manifest, timeout_seconds=45): + if not wait_ready(manifest, timeout_seconds=45, require_identity=True): raise click.ClickException( f"Deployment '{manifest.profile}' did not become ready after start." ) def _stop_deployment(manifest: DeploymentManifest) -> None: - if manifest.supervisor_kind == SupervisorKind.SERVICE.value: - stop_supervisor(manifest) - stop_runtime(manifest) - # Stopping returns before the old process has finished shutting down, so it - # can keep answering /readyz. `_start_deployment` treats a ready endpoint as - # "already running" and would skip the start, leaving the deployment stopped - # once the old process exits. Block until it is really gone. - if not wait_stopped(manifest): - raise click.ClickException( - f"Deployment '{manifest.profile}' is still answering on " - f"{manifest.health_url} after stop." - ) + errors: list[tuple[str, Exception]] = [] + if manifest.supervisor_kind in { + SupervisorKind.SERVICE.value, + SupervisorKind.TASK.value, + }: + try: + stop_supervisor(manifest) + except Exception as exc: + errors.append(("supervisor stop", exc)) + try: + stop_runtime(manifest) + except Exception as exc: + errors.append(("runtime stop", exc)) + try: + if not wait_stopped(manifest): + timeout_error = click.ClickException( + f"Deployment '{manifest.profile}' is still answering on " + f"{manifest.health_url} after stop." + ) + if errors: + errors.append(("runtime shutdown", timeout_error)) + else: + raise timeout_error + except click.ClickException: + raise + except Exception as exc: + errors.append(("runtime shutdown", exc)) + if errors: + raise RuntimeError("; ".join(f"{phase}: {error}" for phase, error in errors)) def _deactivate_deployment_mutations( - manifest: DeploymentManifest, *, persist_manifest: bool = True + manifest: DeploymentManifest, + *, + persist_manifest: bool = True, + restore_backup: bool = False, ) -> None: if not manifest.mutations: return - revert_mutations(manifest) + revert_mutations(manifest, restore_backup=restore_backup) + manifest.mutations = [] if persist_manifest: save_manifest(manifest) @@ -282,33 +319,148 @@ def pending_tool_envs(manifest: DeploymentManifest) -> dict[str, dict[str, str]] return pending +def _track_opencode_backup(manifest: DeploymentManifest, *, activating: bool = False) -> None: + if getattr(manifest, "scope", None) != ConfigScope.PROVIDER.value or "opencode" not in getattr( + manifest, "targets", [] + ): + return + artifacts = getattr(manifest, "artifacts", None) + if artifacts is None: + return + paths = { + mutation.path + for mutation in getattr(manifest, "mutations", []) + if mutation.target == "opencode" and mutation.path + } + if activating: + paths.add(str(opencode_config_path())) + changed = False + for path in sorted(paths): + if any(item.kind == "opencode-config-backup" and item.path == path for item in artifacts): + continue + config_path = Path(path) + backup_path = config_path.with_name(config_path.name + ".headroom-backup") + artifacts.append( + ArtifactRecord( + kind="opencode-config-backup", + path=path, + metadata={"backup_path": str(backup_path)}, + ) + ) + changed = True + if changed: + # Persist ownership before provider mutation or snapshot creation. + _save_apply_manifest(manifest) + + def _activate_deployment_mutations(manifest: DeploymentManifest) -> None: for target, names in sorted(_reconcile_tool_envs(manifest).items()): click.echo(f"Applying newer managed settings for {target}: {', '.join(names)}") - manifest.mutations = apply_mutations(manifest) - save_manifest(manifest) + try: + _track_opencode_backup(manifest, activating=True) + manifest.mutations = apply_mutations(manifest) + _save_apply_manifest(manifest) + except Exception as exc: + if manifest.mutations: + try: + revert_mutations(manifest, restore_backup=False) + + except Exception as rollback_exc: + raise RuntimeError( + f"mutation activation failed: {exc}; rollback failed: {rollback_exc}" + ) from exc + else: + manifest.mutations = [] + raise + + +def _save_apply_manifest(manifest: DeploymentManifest) -> None: + """Use strict persistence for real manifests while keeping helper doubles light.""" + + if isinstance(manifest, DeploymentManifest): + save_manifest_strict(manifest) + else: + save_manifest(manifest) -def _remove_deployment(manifest: DeploymentManifest) -> None: +def _save_recovery_snapshot(manifest: DeploymentManifest, profile: str | None = None) -> None: + if isinstance(manifest, DeploymentManifest): + snapshot = deepcopy(manifest) + if profile is not None: + snapshot.profile = profile + save_recovery_manifest(snapshot) + + +def _delete_recovery_snapshot(profile: str) -> None: + try: + delete_recovery_manifest(profile) + except Exception as exc: + raise click.ClickException( + f"Recovery snapshot {recovery_manifest_path(profile)} could not be deleted: {exc}. " + "It was retained; resolve the filesystem failure and retry." + ) from None + + +def _restore_owned_opencode_backup( + manifest: DeploymentManifest, *, preserve_paths: set[str] | None = None +) -> None: + artifacts = getattr(manifest, "artifacts", None) + if artifacts is None: + return + for artifact in artifacts: + if artifact.kind != "opencode-config-backup" or ( + preserve_paths is not None and artifact.path in preserve_paths + ): + continue + backup_path = artifact.metadata.get("backup_path") + if isinstance(backup_path, str): + restore_opencode_backup(artifact.path, backup_path) + manifest.artifacts = [ + artifact + for artifact in artifacts + if artifact.kind != "opencode-config-backup" + or (preserve_paths is not None and artifact.path in preserve_paths) + ] + + +def _remove_deployment( + manifest: DeploymentManifest, + *, + restore_backup: bool = False, + preserve_opencode_paths: set[str] | None = None, +) -> None: + errors: list[tuple[str, Exception]] = [] try: - _deactivate_deployment_mutations(manifest, persist_manifest=False) - except Exception: - pass + if restore_backup: + _track_opencode_backup(manifest) + if manifest.mutations: + _deactivate_deployment_mutations(manifest, persist_manifest=False, restore_backup=False) + if restore_backup: + _restore_owned_opencode_backup(manifest, preserve_paths=preserve_opencode_paths) + except Exception as exc: + errors.append(("mutation cleanup", exc)) try: _stop_deployment(manifest) - except Exception: - pass + except Exception as exc: + errors.append(("owner stop", exc)) try: remove_supervisor(manifest) - except Exception: - pass - delete_manifest(manifest.profile) + except Exception as exc: + errors.append(("supervisor removal", exc)) + if errors: + raise RuntimeError("; ".join(f"{phase}: {error}" for phase, error in errors)) + try: + delete_manifest(manifest.profile) + except Exception as exc: + raise RuntimeError(f"manifest removal: {exc}") from exc def _restore_deployment(manifest: DeploymentManifest) -> None: restored = deepcopy(manifest) - restored.artifacts = install_supervisor(restored) - save_manifest(restored) + restored.artifacts = [ + artifact for artifact in restored.artifacts if artifact.kind == "opencode-config-backup" + ] + install_supervisor(restored, start=False) + _save_apply_manifest(restored) _start_deployment(restored) _activate_deployment_mutations(restored) @@ -437,6 +589,7 @@ def _build_deployment_manifest( extra_env: dict[str, str] | None = None, supervisor_kind: str | None = None, extra_base_env: dict[str, str] | None = None, + no_rate_limit: bool = False, ) -> DeploymentManifest: manifest = build_manifest( profile=profile, @@ -452,6 +605,7 @@ def _build_deployment_manifest( proxy_mode=proxy_mode, memory_enabled=memory, telemetry_enabled=telemetry and not no_telemetry, + no_rate_limit=no_rate_limit, image=image, no_http2=no_http2, code_aware=code_aware, @@ -502,33 +656,136 @@ def _capture_passthrough_env(environ: Mapping[str, str]) -> dict[str, str]: def _apply_manifest(manifest: DeploymentManifest) -> None: + profile = manifest.profile + recovery_saved = False + active_persistence_failed = False + existing = None + existing_opencode_paths: set[str] = set() try: - existing = load_manifest(manifest.profile) - except ManifestError as e: - # A corrupt existing manifest shouldn't block a fresh apply; overwrite it. - click.echo(f"Warning: {e}; overwriting.") - existing = None - if existing is not None: - click.echo(f"Updating existing deployment profile '{manifest.profile}'...") - _remove_deployment(existing) + try: + existing = load_manifest(profile) + except ManifestError as e: + click.echo(f"Warning: {e}; overwriting.") + if existing is not None: + click.echo(f"Updating existing deployment profile '{profile}'...") + _track_opencode_backup(existing) + existing_opencode_paths = { + artifact.path + for artifact in getattr(existing, "artifacts", []) + if artifact.kind == "opencode-config-backup" + } + manifest.artifacts.extend( + artifact + for artifact in getattr(existing, "artifacts", []) + if artifact.kind == "opencode-config-backup" and artifact not in manifest.artifacts + ) + _save_recovery_snapshot(existing, profile) + recovery_saved = True + _remove_deployment(existing) + + except Exception as exc: + recovery_detail = ( + f" Recovery snapshot: {recovery_manifest_path(profile)} is retained; " + "no new owner was started." + if recovery_saved + else " No new owner was started." + ) + raise click.ClickException( + f"Failed to prepare deployment '{profile}': {exc}.{recovery_detail}" + ) from exc try: - manifest.artifacts = install_supervisor(manifest) - save_manifest(manifest) + try: + _save_apply_manifest(manifest) + except Exception: + active_persistence_failed = True + raise + manifest.artifacts = [ + artifact for artifact in manifest.artifacts if artifact.kind == "opencode-config-backup" + ] + install_supervisor(manifest, start=False) + try: + _save_apply_manifest(manifest) + except Exception: + active_persistence_failed = True + raise _start_deployment(manifest) - _activate_deployment_mutations(manifest) + try: + _activate_deployment_mutations(manifest) + except Exception: + active_persistence_failed = True + raise except Exception as exc: - _remove_deployment(manifest) - if existing is not None: - click.echo(f"Restoring previous deployment '{manifest.profile}'...") - _restore_deployment(existing) - # Surface non-Click errors (OSError, CalledProcessError, ...) as a clean - # message rather than a raw traceback; Click errors pass through as-is. + cleanup_errors: list[Exception] = [] + try: + _remove_deployment( + manifest, + restore_backup=True, + preserve_opencode_paths=existing_opencode_paths, + ) + except Exception as cleanup_exc: + cleanup_errors.append(cleanup_exc) + if not cleanup_errors and not active_persistence_failed and existing is not None: + click.echo(f"Restoring previous deployment '{profile}'...") + try: + _restore_deployment(existing) + except Exception as restore_error: + raise click.ClickException( + f"Failed to install deployment '{profile}': {exc}; " + f"previous deployment restoration also failed: {restore_error}; " + f"recovery snapshot: {recovery_manifest_path(profile)}; " + "restore it after resolving the failure" + ) from exc + _delete_recovery_snapshot(profile) + + def _recovery_detail() -> str: + if recovery_saved: + return ( + f"; recovery snapshot: {recovery_manifest_path(profile)}; " + "remove the new owner before restoring the snapshot" + ) + return "" + + cleanup_detail = "" + if cleanup_errors: + cleanup_detail = "; cleanup also failed: " + " | ".join(map(str, cleanup_errors)) + persistence_detail = ( + "; active manifest persistence failed; keep the recovery snapshot" + if active_persistence_failed and recovery_saved + else "" + ) if isinstance(exc, click.ClickException | click.Abort): + if cleanup_errors or persistence_detail: + raise click.ClickException( + f"Failed to install deployment '{profile}': {exc}" + f"{cleanup_detail}{persistence_detail}{_recovery_detail()}" + ) from exc raise - raise click.ClickException( - f"Failed to install deployment '{manifest.profile}': {exc}" - ) from exc + if cleanup_errors or persistence_detail: + raise click.ClickException( + f"Failed to install deployment '{profile}': {exc}" + f"{cleanup_detail}{persistence_detail}{_recovery_detail()}" + ) from exc + raise click.ClickException(f"Failed to install deployment '{profile}': {exc}") from exc + active_opencode_paths = { + mutation.path + for mutation in manifest.mutations + if mutation.target == "opencode" and mutation.path + } + if any( + artifact.kind == "opencode-config-backup" and artifact.path not in active_opencode_paths + for artifact in manifest.artifacts + ): + try: + _restore_owned_opencode_backup(manifest, preserve_paths=active_opencode_paths) + _save_apply_manifest(manifest) + except Exception as exc: + raise click.ClickException( + f"Deployment '{profile}' is active, but OpenCode snapshot restoration failed: " + f"{exc}. Backup ownership is retained in the deployment manifest; " + "resolve the filesystem failure and retry install apply." + ) from exc + if recovery_saved: + _delete_recovery_snapshot(profile) def _echo_installed(manifest: DeploymentManifest, *, prefix: str = "Installed persistent") -> None: @@ -620,6 +877,18 @@ def _echo_installed(manifest: DeploymentManifest, *, prefix: str = "Installed pe is_flag=True, help="Force anonymous telemetry off in the runtime (already the default).", ) +@click.option( + "--no-rate-limit", + "no_rate_limit", + is_flag=True, + default=False, + help=( + "Disable the proxy's built-in rate limiter (default: 60 req/min). " + "Recommended for always-on agentic targets (Claude Code, Codex) that " + "burst above the default threshold. The flag is persisted in the " + "deployment manifest so reinstalls don't silently reintroduce throttling." + ), +) @click.option( "--image", default="ghcr.io/headroomlabs-ai/headroom:latest", @@ -691,6 +960,7 @@ def install_apply( memory: bool, telemetry: bool, no_telemetry: bool, + no_rate_limit: bool, image: str, no_http2: bool, code_aware: bool | None, @@ -737,6 +1007,7 @@ def install_apply( memory=memory, telemetry=telemetry, no_telemetry=no_telemetry, + no_rate_limit=no_rate_limit, image=image, no_http2=no_http2, code_aware=code_aware, @@ -907,9 +1178,23 @@ def install_status(profile: str) -> None: # reach `.get('backend', ...)` and crash with AttributeError. Guard on # isinstance, mirroring wrap.py's _proxy_health_config. config = payload.get("config") - if not isinstance(config, dict): + anthropic_target = openai_target = "unknown" + if isinstance(config, dict): + anthropic_url = config.get("anthropic_api_url") + if "anthropic_api_url" in config and ( + anthropic_url is None or isinstance(anthropic_url, str) + ): + anthropic_target = "configured" if anthropic_url else "default" + openai_url = config.get("openai_api_url") + if "openai_api_url" in config and (openai_url is None or isinstance(openai_url, str)): + openai_target = "configured" if openai_url else "default" + else: + # Network health probes may intentionally omit credential-bearing + # config. Missing information does not mean a target is unset. config = {} - click.echo(f"Backend: {config.get('backend', manifest.backend)}") + click.echo(f"Default backend: {config.get('backend', manifest.backend)}") + click.echo(f"Anthropic target: {anthropic_target}") + click.echo(f"OpenAI target: {openai_target}") @install.command("start") @@ -962,21 +1247,13 @@ def install_remove(profile: str) -> None: """Remove a persistent deployment and undo managed config.""" manifest = _require_manifest(profile) - _deactivate_deployment_mutations(manifest, persist_manifest=False) - try: - if manifest.supervisor_kind == SupervisorKind.SERVICE.value: - stop_supervisor(manifest) - except Exception: - pass - try: - stop_runtime(manifest) - except Exception: - pass try: - remove_supervisor(manifest) - except Exception: - pass - delete_manifest(profile) + _remove_deployment(manifest, restore_backup=True) + except Exception as exc: + raise click.ClickException( + f"Failed to remove deployment '{profile}': cleanup failed: {exc}. " + "The deployment manifest was retained; resolve the cleanup failure and retry." + ) from exc click.echo(f"Removed deployment '{profile}'.") @@ -1003,7 +1280,7 @@ def install_agent_ensure(profile: str) -> None: """Ensure a persistent deployment is healthy, starting it when needed.""" manifest = _require_manifest(profile) - if probe_ready(manifest.health_url): + if runtime_status(manifest) == "running" and probe_ready(manifest.health_url): click.echo(f"Deployment '{profile}' is already healthy.") return with acquire_runtime_start_lock(manifest.profile) as acquired: @@ -1012,13 +1289,15 @@ def install_agent_ensure(profile: str) -> None: return # Double-check after acquiring the lock — another ensure may have # started the runtime while we waited for the lock. - if probe_ready(manifest.health_url): + if runtime_status(manifest) == "running" and probe_ready(manifest.health_url): click.echo(f"Deployment '{profile}' is already healthy.") return if runtime_status(manifest) == "running": # Runtime exists but isn't ready yet — give it a grace period # before deciding it's wedged and restarting. - if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS): + if wait_ready( + manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS, require_identity=True + ): click.echo(f"Deployment '{profile}' is healthy.") return _deactivate_deployment_mutations(manifest) diff --git a/headroom/cli/learn.py b/headroom/cli/learn.py index 4a469e45ff..47269c11f4 100644 --- a/headroom/cli/learn.py +++ b/headroom/cli/learn.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import click @@ -13,6 +13,16 @@ from .main import main +def _projects_at(projects: list[Any], path: Path) -> list[Any]: + """Projects checked out at ``path``, including linked worktrees merged into one. + + ``getattr``: external plugins may return duck-typed projects without the field. + """ + return [ + p for p in projects if p.project_path == path or path in getattr(p, "worktree_paths", ()) + ] + + class _AgentChoice(click.ParamType): """Dynamic Click type that validates against the plugin registry.""" @@ -161,7 +171,7 @@ def learn( """ import os - from ..learn.analyzer import SessionAnalyzer, _detect_default_model + from ..learn.analyzer import SessionAnalyzer, _detect_default_model, _require_agy_opt_in from ..learn.registry import auto_detect_plugins, get_plugin # Flag-combination validation — reject contradictory/no-op combinations up @@ -202,7 +212,9 @@ def learn( # Resolve model early to fail fast with a clear message try: resolved_model = model or _detect_default_model() - except RuntimeError as e: + if resolved_model == "agy-cli": + _require_agy_opt_in() + except (RuntimeError, ValueError) as e: click.echo(f"Error: {e}") raise SystemExit(1) from None @@ -255,15 +267,15 @@ def _on_progress(detail: str) -> None: targets = all_projects elif project: resolved = project.resolve() - targets = [p for p in all_projects if p.project_path == resolved] + targets = _projects_at(all_projects, resolved) if not targets: continue else: cwd = Path.cwd().resolve() - targets = [p for p in all_projects if p.project_path == cwd] + targets = _projects_at(all_projects, cwd) if not targets: for parent in cwd.parents: - targets = [p for p in all_projects if p.project_path == parent] + targets = _projects_at(all_projects, parent) if targets: break if not targets and len(agent_configs) == 1: @@ -406,7 +418,167 @@ def judge(signals: dict) -> tuple[int, str] | None: return judge -def _activate_output_shaper(port: int | None = None) -> tuple[str, int]: +def _local_proxy_port(port: int | None = None) -> int: + import os as _os + + return port if port is not None else int(_os.environ.get("HEADROOM_PORT", "8787")) + + +def _steered_level(raw: object) -> int | None: + """The level a proxy steers at for a raw ``HEADROOM_VERBOSITY_LEVEL``. + + Mirrors ``OutputShaperSettings.from_env``: unparseable text falls back to + the default level, anything else is clamped to 0-4. ``None`` when unset. + """ + from ..proxy.output_shaper import DEFAULT_VERBOSITY_LEVEL + + if raw is None or str(raw).strip() == "": + return None + try: + level = int(str(raw).strip()) + except ValueError: + level = DEFAULT_VERBOSITY_LEVEL + return max(0, min(4, level)) + + +class _ProxyProcess(NamedTuple): + """Process identity and its raw verbosity pin from the same health snapshot.""" + + pid: int + started_at: float + verbosity_pin: str | None = None + + +def _proxy_process(payload: dict[str, Any], config: dict[str, Any]) -> _ProxyProcess | None: + """The proxy process behind a ``/health`` payload, or ``None`` if unknown. + + The start time is ``timestamp - uptime_seconds``: the proxy computes both + from ``time.time()`` while building the payload, so the difference is its + start time to the millisecond, the same on every call. + """ + from datetime import datetime + + pid = config.get("pid") + timestamp = payload.get("timestamp") + uptime = payload.get("uptime_seconds") + if not isinstance(pid, int) or not isinstance(timestamp, str): + return None + # A proxy that has not recorded its start reports 0.0; that is not an identity. + if not isinstance(uptime, int | float) or uptime <= 0: + return None + try: + now = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + runtime = config.get("runtime_env") + raw_pin = runtime.get("HEADROOM_VERBOSITY_LEVEL") if isinstance(runtime, dict) else None + return _ProxyProcess( + pid, round(now - float(uptime), 3), raw_pin if isinstance(raw_pin, str) else None + ) + + +def _query_proxy_verbosity( + port: int | None = None, +) -> tuple[str | None, int | None, _ProxyProcess | None]: + """Best-effort: read a running local proxy's mode, pinned level and process. + + All three come from ``/health``, whose config block the proxy includes for + loopback callers. Returns ``(mode, level, process)``, each ``None`` when the + proxy is unreachable or does not report it; ``level`` is the level its + ``HEADROOM_VERBOSITY_LEVEL`` pins, ``None`` when that is unset. + """ + import json as _json + import urllib.error + import urllib.request + + url = f"http://127.0.0.1:{_local_proxy_port(port)}/health" + try: + with urllib.request.urlopen(url, timeout=2) as response: + payload = _json.loads(response.read() or b"{}") + except (urllib.error.URLError, OSError, ValueError): + return None, None, None + config = payload.get("config") if isinstance(payload, dict) else None + if not isinstance(config, dict): + return None, None, None + mode = config.get("mode") + runtime = config.get("runtime_env") + level = runtime.get("HEADROOM_VERBOSITY_LEVEL") if isinstance(runtime, dict) else None + return ( + mode if isinstance(mode, str) else None, + _steered_level(level), + _proxy_process(payload, config), + ) + + +#: Records the HEADROOM_VERBOSITY_LEVEL that ``--apply`` hot-pinned, and on +#: which proxy process. Hot overrides die with the process, so a pin is ours to +#: replace only while that same process still holds that value. +_VERBOSITY_PIN_FILE = "verbosity_pin.json" + +#: Slack when comparing recorded and current start times, which agree to the +#: millisecond for one process; a different process started this close to the +#: old one would also need its pid, port and level to match. +_START_TIME_TOLERANCE_S = 1.0 + + +def _pin_is_ours(ws: Path, port: int, process: _ProxyProcess | None, level: int | None) -> bool: + import json as _json + + if process is None or level is None: + return False + try: + record = _json.loads((ws / _VERBOSITY_PIN_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + if not isinstance(record, dict): + return False + started_at = record.get("started_at") + return ( + record.get("port") == port + and record.get("pid") == process.pid + and record.get("level") == level + and process.verbosity_pin == str(level) + and isinstance(started_at, int | float) + and abs(started_at - process.started_at) <= _START_TIME_TOLERANCE_S + ) + + +def _record_pin(ws: Path, port: int, process: _ProxyProcess | None, level: int) -> bool: + """Record a pin ``--apply`` set. Returns False when it could not be recorded. + + Best-effort: the pin is already live on the proxy, so a failed record only + means a later ``--apply`` treats it as the operator's and leaves it alone. + """ + import json as _json + + path = ws / _VERBOSITY_PIN_FILE + try: + if process is None: + # Without a process identity the pin cannot be attributed later. + path.unlink(missing_ok=True) + return False + record = { + "port": port, + "pid": process.pid, + "started_at": process.started_at, + "level": level, + } + path.write_text(_json.dumps(record), encoding="utf-8", newline="\n") + except OSError: + return False + return True + + +class _ShaperActivation(NamedTuple): + status: str + port: int + #: The overrides the proxy reports it applied (empty if it did not answer). + applied: dict[str, str] + + +def _activate_output_shaper( + port: int | None = None, *, verbosity_level: str | None = None +) -> _ShaperActivation: """Best-effort: turn the output shaper ON for a running local proxy. Writing ``verbosity.json`` is inert on its own — the shaper is a live, @@ -414,20 +586,24 @@ def _activate_output_shaper(port: int | None = None) -> tuple[str, int]: ``HEADROOM_OUTPUT_SHAPER`` is enabled in the proxy that serves traffic. 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), ``"blocked"`` (the proxy's - rollout channel rejected it), ``"absent"`` (no reachable proxy), or + ``--apply`` actually takes effect. ``verbosity_level`` also pins + ``HEADROOM_VERBOSITY_LEVEL`` in the same request. Returns ``(status, port, + applied)`` where status is ``"live"`` (enabled on a running proxy), + ``"blocked"`` (the proxy's rollout config disables the shaper; the proxy + still applies the overrides), ``"absent"`` (no reachable proxy), or ``"error"``. """ import json as _json - import os as _os import urllib.error import urllib.request - resolved_port = port if port is not None else int(_os.environ.get("HEADROOM_PORT", "8787")) + resolved_port = _local_proxy_port(port) + overrides = {"HEADROOM_OUTPUT_SHAPER": "1"} + if verbosity_level is not None: + overrides["HEADROOM_VERBOSITY_LEVEL"] = verbosity_level request = urllib.request.Request( f"http://127.0.0.1:{resolved_port}/admin/runtime-env", - data=_json.dumps({"HEADROOM_OUTPUT_SHAPER": "1"}).encode("utf-8"), + data=_json.dumps(overrides).encode("utf-8"), method="POST", headers={"Content-Type": "application/json"}, ) @@ -435,6 +611,12 @@ def _activate_output_shaper(port: int | None = None) -> tuple[str, int]: with urllib.request.urlopen(request, timeout=2) as response: raw_response = response.read() payload = _json.loads(raw_response) if raw_response else {} + applied_raw = payload.get("applied") if isinstance(payload, dict) else None + applied = ( + {k: v for k, v in applied_raw.items() if isinstance(v, str)} + if isinstance(applied_raw, dict) + else {} + ) rollout = payload.get("rollout") if isinstance(payload, dict) else None if isinstance(rollout, dict): decisions = rollout.get("features") @@ -448,13 +630,13 @@ def _activate_output_shaper(port: int | None = None) -> tuple[str, int]: None, ) if isinstance(output_shaper, dict) and not output_shaper.get("enabled", False): - return "blocked", resolved_port - return "live", resolved_port + return _ShaperActivation("blocked", resolved_port, applied) + return _ShaperActivation("live", resolved_port, applied) except (urllib.error.URLError, OSError): # ConnectionRefused (no proxy) or 404 (proxy predates the endpoint). - return "absent", resolved_port + return _ShaperActivation("absent", resolved_port, {}) except ValueError: - return "error", resolved_port + return _ShaperActivation("error", resolved_port, {}) def _run_verbosity( @@ -494,13 +676,13 @@ def _run_verbosity( targets = all_projects elif project: resolved = project.resolve() - targets = [p for p in all_projects if p.project_path == resolved] + targets = _projects_at(all_projects, resolved) else: cwd = Path.cwd().resolve() - targets = [p for p in all_projects if p.project_path == cwd] + targets = _projects_at(all_projects, cwd) if not targets: for parent in cwd.parents: - targets = [p for p in all_projects if p.project_path == parent] + targets = _projects_at(all_projects, parent) if targets: break if not targets: @@ -519,7 +701,9 @@ def _run_verbosity( analyzed_count = 0 for proj in targets: - session_paths = sorted(proj.data_path.glob("*.jsonl")) + session_paths = sorted( + f for d in (proj.data_path, *proj.extra_data_paths) for f in d.glob("*.jsonl") + ) if not session_paths: continue profile, baseline = analyze(session_paths, str(proj.project_path), llm_judge=judge) @@ -579,24 +763,79 @@ def _run_verbosity( # Writing the level is not enough — the shaper is off by default. # Make --apply actually take effect: hot-enable a running proxy, and # otherwise tell the user exactly how to turn it on. - status, shaper_port = _activate_output_shaper() - if status == "live": + level = best_profile.level + port = _local_proxy_port() + mode, pinned, process = _query_proxy_verbosity(port) + # Cache mode never reads verbosity.json: a level that can change while + # conversations are open would bust their prefix cache, so the proxy + # steers at its startup level unless HEADROOM_VERBOSITY_LEVEL pins one + # (resolve_verbosity_level). Pin the learned level there; otherwise the + # proxy would keep steering at its default while we report this level. + # An existing pin is the operator's and is left alone, unless the record + # from an earlier --apply shows this same proxy process holds the value + # that run pinned. + ours = _pin_is_ours(ws, port, process, pinned) + pin = str(level) if mode == "cache" and (pinned is None or ours) else None + status, shaper_port, applied = _activate_output_shaper(port, verbosity_level=pin) + # Record the pin whenever the proxy applied it: it stores overrides even + # when its rollout config disables the shaper ("blocked"). + pin_applied = pin is not None and applied.get("HEADROOM_VERBOSITY_LEVEL") == pin + pin_recorded = pin_applied and _record_pin(ws, shaper_port, process, level) + cache_mode_hint = ( + f"in cache mode (the default) also set HEADROOM_VERBOSITY_LEVEL={level}, " + "because cache mode does not read the learned level" + ) + if status == "live" and pinned is not None and pin is None and pinned != level: + click.echo( + f"\n ⚠ Output shaper enabled on the running proxy (port {shaper_port}), " + f"but it stays at level {pinned}: HEADROOM_VERBOSITY_LEVEL is set there " + "and outranks the learned level." + ) + click.echo( + f" To use level {level}, restart the proxy with HEADROOM_VERBOSITY_LEVEL={level}." + ) + elif status == "live" and mode is None: + click.echo( + f"\n ⚠ Output shaper enabled on the running proxy (port {shaper_port}), but " + f"its mode could not be read, so level {level} may not be in use." + ) + click.echo( + f" A proxy in cache mode (the default) keeps its startup level; restart it " + f"with HEADROOM_OUTPUT_SHAPER=1 HEADROOM_VERBOSITY_LEVEL={level} to be sure." + ) + elif status == "live": + how = ( + " (pinned with HEADROOM_VERBOSITY_LEVEL: cache mode does not read the " + "learned level)" + if pin + else "" + ) click.echo( f"\n ✓ Output shaper enabled on the running proxy (port {shaper_port}); " - f"level {best_profile.level} is live now (while HEADROOM_VERBOSITY_LEVEL is unset)." + f"level {level} is live now{how}." + ) + if pin and pinned != level: + click.echo( + " Conversations already open re-cache their prompt once, because " + "the steering text changed." + ) + restart_env = "HEADROOM_OUTPUT_SHAPER=1" + ( + f" HEADROOM_VERBOSITY_LEVEL={level}" if mode == "cache" else "" ) click.echo( - " To keep it on across restarts: export HEADROOM_ROLLOUT_CHANNEL=beta " - "and HEADROOM_OUTPUT_SHAPER=1 before `headroom wrap ...`." + f" To keep it on across restarts: export {restart_env} " + "before `headroom wrap ...` or `headroom proxy`." ) elif status == "blocked": + # proxy_output_shaper is available on every channel, so only an + # explicit disable (HEADROOM_DISABLE_FEATURES) refuses the hot enable. click.echo( - "\n ⚠ Level written, but the running proxy's rollout channel blocks the " - "beta output shaper." + "\n ⚠ Level written, but the running proxy's rollout config disables the " + "output shaper." ) click.echo( - " Restart it with HEADROOM_ROLLOUT_CHANNEL=beta and " - "HEADROOM_OUTPUT_SHAPER=1; the learned level will be used automatically." + " Restart it without proxy_output_shaper in HEADROOM_DISABLE_FEATURES " + f"and with HEADROOM_OUTPUT_SHAPER=1; {cache_mode_hint}." ) else: click.echo( @@ -604,10 +843,15 @@ def _run_verbosity( "NOT shaping output yet." ) click.echo( - " 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." + " Enable it: export HEADROOM_OUTPUT_SHAPER=1, then run `headroom wrap ...` " + "(or restart `headroom proxy`). A token-mode proxy then uses the learned " + f"level; {cache_mode_hint}." + ) + if pin_applied and not pin_recorded: + click.echo( + f" Note: the HEADROOM_VERBOSITY_LEVEL={level} pin could not be recorded in " + f"{ws / _VERBOSITY_PIN_FILE}, so a later --apply will leave it in place as " + "if you had set it." ) else: click.echo("\n Dry run — use --apply to persist the level and baseline.") diff --git a/headroom/cli/main.py b/headroom/cli/main.py index bd2fd7fce3..23ebd77cbf 100644 --- a/headroom/cli/main.py +++ b/headroom/cli/main.py @@ -2,9 +2,39 @@ import click +from headroom.offline import OfflineEgressBlocked + CLI_CONTEXT_SETTINGS = {"help_option_names": ["--help", "-?"]} +class OfflineAwareGroup(click.Group): + """The CLI's single translation point for an air-gap refusal. + + ``OfflineEgressBlocked`` is a ``BaseException`` on purpose, so no + ``except Exception`` anywhere in the tree can downgrade a policy refusal + into "that feature is having a bad day". The cost of that choice is that + nothing catches it either — Click's own error handling only knows about + ``ClickException``/``Abort``, so an operator who ran ``headroom auth + copilot`` on an air-gapped box would have got a stack trace ending in a + type they have never heard of. + + Translating here, once, at the outermost boundary of every subcommand, + turns it into the ordinary ``Error: ...`` line Click prints for any other + bad invocation — with the switch, the feature and the host already in the + message, because :class:`OfflineEgressBlocked` builds them in. The guard + keeps its unswallowable property everywhere inside; only the last frame + before the user's terminal is allowed to make it readable. + """ + + def invoke(self, ctx: click.Context) -> object: + try: + return super().invoke(ctx) + except OfflineEgressBlocked as blocked: + # ``from None``: the traceback is Headroom's own plumbing and + # tells the operator nothing they can act on. The message does. + raise click.ClickException(str(blocked)) from None + + def get_version() -> str: """Get the current version.""" try: @@ -15,7 +45,7 @@ def get_version() -> str: return "unknown" -@click.group(context_settings=CLI_CONTEXT_SETTINGS) +@click.group(cls=OfflineAwareGroup, context_settings=CLI_CONTEXT_SETTINGS) @click.version_option(get_version(), "--version", "-v", prog_name="headroom") @click.pass_context def main(ctx: click.Context) -> None: diff --git a/headroom/cli/mcp.py b/headroom/cli/mcp.py index bfcf3e1343..8013302937 100644 --- a/headroom/cli/mcp.py +++ b/headroom/cli/mcp.py @@ -5,78 +5,16 @@ needing API key access. """ -import json -from pathlib import Path -from typing import Any - import click from .main import main -# Default paths -CLAUDE_CONFIG_DIR = Path.home() / ".claude" -MCP_CONFIG_PATH = CLAUDE_CONFIG_DIR / "mcp.json" -# Servers registered via `claude mcp add` (user scope) live in ~/.claude.json, -# NOT in ~/.claude/mcp.json. Status must check both to avoid a false negative. -CLAUDE_JSON_PATH = Path.home() / ".claude.json" DEFAULT_PROXY_URL = "http://127.0.0.1:8787" DEFAULT_HTTP_HOST = "127.0.0.1" DEFAULT_HTTP_PORT = 8788 DEFAULT_HTTP_PATH = "/mcp" -def get_headroom_command() -> list[str]: - """Get the command to run headroom MCP server. - - Returns the CLI invocation used by Claude Code config. - """ - return ["headroom", "mcp", "serve"] - - -def load_mcp_config() -> dict[str, Any]: - """Load existing MCP config or return empty structure.""" - if MCP_CONFIG_PATH.exists(): - try: - with open(MCP_CONFIG_PATH, encoding="utf-8") as f: - result: dict[str, Any] = json.load(f) - return result - except (json.JSONDecodeError, OSError): - return {"mcpServers": {}} - return {"mcpServers": {}} - - -def save_mcp_config(config: dict) -> None: - """Save MCP config, creating directory if needed.""" - CLAUDE_CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - f.write("\n") # Trailing newline - - -def find_headroom_registration() -> tuple[Path, dict[str, Any]] | None: - """Locate an existing 'headroom' MCP server registration. - - Claude Code stores servers registered with `claude mcp add` (user scope) in - ~/.claude.json under "mcpServers". Headroom's own `mcp install` fallback - writes ~/.claude/mcp.json, and a project may define ./.mcp.json. Check all of - them so `status` reflects reality instead of only looking at mcp.json. - - Returns (config_path, server_config) for the first match, else None. - """ - for path in (CLAUDE_JSON_PATH, MCP_CONFIG_PATH, Path.cwd() / ".mcp.json"): - if not path.exists(): - continue - try: - with open(path) as f: - data = json.load(f) - except (json.JSONDecodeError, OSError): - continue - servers = data.get("mcpServers", {}) - if isinstance(servers, dict) and "headroom" in servers: - return path, servers["headroom"] - return None - - @main.group() def mcp() -> None: """MCP server for Claude Code integration. @@ -139,9 +77,9 @@ def mcp_install(proxy_url: str, agents: tuple[str, ...], force: bool) -> None: \b By default this installs into every agent that has a registrar and is - detected on this system (Claude Code today; Cursor / Codex / Continue / - others added in subsequent releases). Pass ``--agent NAME`` one or more - times to restrict the installation. + detected on this system (Antigravity IDE, Claude Code, Codex, Grok CLI, + OpenCode today; Cursor / Continue / others added in subsequent releases). + Pass ``--agent NAME`` one or more times to restrict the installation. \b Examples: @@ -188,18 +126,38 @@ def mcp_install(proxy_url: str, agents: tuple[str, ...], force: bool) -> None: @mcp.command("uninstall") -def mcp_uninstall() -> None: +@click.option( + "--agent", + "agents", + multiple=True, + help="Restrict uninstallation to specific agents (default: every detected agent).", +) +def mcp_uninstall(agents: tuple[str, ...]) -> None: """Remove Headroom MCP server from detected agent configs. \b Removes headroom from every agent registrar known to Headroom. Other MCP - servers are preserved. + servers are preserved. Pass ``--agent NAME`` one or more times to restrict + the uninstallation. + + \b + Examples: + headroom mcp uninstall # every detected agent + headroom mcp uninstall --agent antigravity # Antigravity IDE only """ from headroom.mcp_registry import get_all_registrars + registrars = get_all_registrars() + if agents: + wanted = {agent.strip().lower() for agent in agents if agent.strip()} + registrars = [registrar for registrar in registrars if registrar.name in wanted] + if not registrars: + click.echo("No agents matched the requested filter.") + raise SystemExit(1) + removed = False - for registrar in get_all_registrars(): + for registrar in registrars: if not registrar.detect(): continue removed_names: list[str] = [] diff --git a/headroom/cli/perf.py b/headroom/cli/perf.py index f92108ebcb..148c12e52d 100644 --- a/headroom/cli/perf.py +++ b/headroom/cli/perf.py @@ -82,8 +82,11 @@ def perf(hours: float, raw: bool, output_format: str) -> None: "tokens_saved", "message_tokens_saved", "tool_tokens_saved", + "prompt_reduction_tokens", + "accounting_delta_tokens", "savings_pct", "list_price_per_mtok", + "estimated_list_price_savings_usd", ] writer = csv.DictWriter(buf, fieldnames=fieldnames) writer.writeheader() diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index d9b5bfb1fb..3da788fd51 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -2,9 +2,10 @@ import logging import os +import platform import sys import warnings -from importlib import import_module +from importlib.util import find_spec from pathlib import Path from typing import Any, Literal, cast @@ -21,6 +22,15 @@ from .main import main +def _proxy_requires_onnx_backends() -> bool: + """Match extras that omit unavailable Intel macOS Python 3.14+ wheels.""" + return not ( + sys.platform == "darwin" + and platform.machine() == "x86_64" + and sys.version_info[:2] >= (3, 14) + ) + + def ensure_proxy_dependencies() -> None: """Verify optional proxy extras are installed before starting or wrapping.""" required_modules: list[str] = [ @@ -29,18 +39,19 @@ def ensure_proxy_dependencies() -> None: "httpx", "openai", "mcp", - "magika", "zstandard", "websockets", - "onnxruntime", "transformers", ] + if _proxy_requires_onnx_backends(): + required_modules.extend(["magika", "onnxruntime"]) if sys.implementation.name != "pypy": required_modules.append("orjson") try: - for module in required_modules: - import_module(module) + missing = next((module for module in required_modules if find_spec(module) is None), None) + if missing is not None: + raise ImportError(f"No module named '{missing}'") except ImportError as e: click.secho( "Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy]", @@ -184,25 +195,25 @@ def _get_env_int(name: str, default: int) -> int: return default if value is None else value -def _get_env_float_optional(name: str) -> float | None: - val = os.environ.get(name) - if val is None or val == "": - return None - try: - return float(val) - except ValueError: - raise click.ClickException(f"{name} must be a number, got {val!r}") from None - - def _refuse_tcp_listen_options_with_uds(ctx: click.Context) -> None: """Reject ``--host``/``--port`` (from the command line or environment) alongside ``--uds``. - A unix socket listener binds no TCP address, so either value would be silently ignored. + A unix socket listener binds no TCP address, so either value would be silently ignored. A host or port that only a stored setting supplied is not a request for a TCP listener, so it is not refused: otherwise one dashboard-saved port would stop every supervised ``--uds`` start. """ - explicit = {click.core.ParameterSource.COMMANDLINE, click.core.ParameterSource.ENVIRONMENT} - conflicting = [ - f"--{name}" for name in ("host", "port") if ctx.get_parameter_source(name) in explicit - ] + from headroom import settings_store + + env_names = {"host": "HEADROOM_HOST", "port": "HEADROOM_PORT"} + + def explicit(name: str) -> bool: + source = ctx.get_parameter_source(name) + if source == click.core.ParameterSource.COMMANDLINE: + return True + return ( + source == click.core.ParameterSource.ENVIRONMENT + and not settings_store.env_came_from_store(env_names[name]) + ) + + conflicting = [f"--{name}" for name in ("host", "port") if explicit(name)] if conflicting: raise click.UsageError( f"--uds cannot be combined with {', '.join(conflicting)} " @@ -237,32 +248,46 @@ def dashboard(port: int, no_open: bool) -> None: @main.command() +@click.option( + "--headroom-deployment-profile", + hidden=True, + expose_value=False, + help="Internal persistent-deployment identity marker.", +) +@click.option( + "--headroom-deployment-runtime", + hidden=True, + expose_value=False, + help="Internal persistent-deployment identity marker.", +) @click.option( "--host", default="127.0.0.1", envvar="HEADROOM_HOST", help="Host to bind to (default: 127.0.0.1, env: HEADROOM_HOST)", ) -@click.option( - "--port", - "-p", - default=8787, - type=click.IntRange(1, 65535), - envvar="HEADROOM_PORT", - help="Port to bind to (default: 8787, env: HEADROOM_PORT)", -) @click.option( "--uds", default=None, + envvar="HEADROOM_UDS", + metavar="PATH", type=click.Path(dir_okay=False, resolve_path=False), help=( "Listen on this unix domain socket path instead of a TCP host and port. " - "Cannot be combined with --host/--port or HEADROOM_HOST/HEADROOM_PORT. " + "POSIX only. Cannot be combined with --host/--port or HEADROOM_HOST/HEADROOM_PORT. " "The parent directory must already exist and be private (mode 0700); " "the socket is created with mode 0600, a stale socket at the path is " - "replaced, and the socket is removed on shutdown." + "replaced, and the socket is removed on shutdown (env: HEADROOM_UDS)." ), ) +@click.option( + "--port", + "-p", + default=8787, + type=click.IntRange(1, 65535), + envvar="HEADROOM_PORT", + help="Port to bind to (default: 8787, env: HEADROOM_PORT)", +) @click.option( "--allowed-base-urls-file", default=None, @@ -568,6 +593,18 @@ def dashboard(port: int, no_open: bool) -> None: "Env: HEADROOM_WRITE_TIMEOUT_SECONDS." ), ) +@click.option( + "--upstream-tcp-keepalive-seconds", + type=click.IntRange(min=0), + default=None, + envvar="HEADROOM_UPSTREAM_TCP_KEEPALIVE_SECONDS", + help=( + "Seconds an upstream connection may sit silent before TCP keepalive " + "probes it (default: 30, 0 disables). A link that dies without a reset " + "then fails over after about this + 60s instead of waiting out the read " + "timeout. Env: HEADROOM_UPSTREAM_TCP_KEEPALIVE_SECONDS." + ), +) @click.option( "--anthropic-buffered-request-timeout-seconds", type=click.IntRange(min=1), @@ -777,9 +814,9 @@ def dashboard(port: int, no_open: bool) -> None: envvar="HEADROOM_READ_MATURATION", help=( "EXPERIMENTAL: activity-based read maturation — hold fresh Reads " - "out of the provider prefix cache and compress them once their " - "file quiesces. Requires HEADROOM_ROLLOUT_CHANNEL=beta (or dev); " - "env: HEADROOM_READ_MATURATION=1." + "verbatim and compress them once their file quiesces, unless the " + "provider cache already holds them. Requires " + "HEADROOM_ROLLOUT_CHANNEL=beta (or dev); env: HEADROOM_READ_MATURATION=1." ), ) @click.option( @@ -1068,8 +1105,8 @@ def proxy( mode: str | None, target_ratio: float | None, host: str, - port: int, uds: str | None, + port: int, allowed_base_urls_file: str | None, workers: int, limit_concurrency: int, @@ -1099,6 +1136,7 @@ def proxy( request_timeout_seconds: int | None, connect_timeout_seconds: int | None, write_timeout_seconds: int | None, + upstream_tcp_keepalive_seconds: int | None, anthropic_buffered_request_timeout_seconds: int | None, anthropic_pre_upstream_concurrency: int | None, anthropic_pre_upstream_acquire_timeout_seconds: float | None, @@ -1186,11 +1224,13 @@ def proxy( raise click.ClickException(str(exc)) from None _reexec_with_malloc_tuning() + ensure_proxy_dependencies() # Import here to avoid slow startup from headroom.proxy.server import ( ProxyConfig, + _get_env_optional_bool, _parse_csv_tools, _parse_exclude_tools, _parse_tool_profiles, @@ -1344,12 +1384,20 @@ def proxy( _paths.codex_wire_debug_dir() ) - # Stateless mode: suppress TOIN filesystem persistence + # Stateless mode: suppress TOIN filesystem persistence, and export the flag + # so code that runs before the proxy records it (the update check) and + # child processes see the same answer as paths.process_is_stateless(). if is_stateless: os.environ["HEADROOM_TOIN_BACKEND"] = "none" + os.environ["HEADROOM_STATELESS"] = "1" - # License key for managed/enterprise deployments (optional) - license_key = os.environ.get("HEADROOM_LICENSE_KEY") + # Licence token (HEADROOM_LICENSE; HEADROOM_LICENSE_KEY is a deprecated + # alias). Having one set never enables outbound usage reporting: that + # needs the explicit HEADROOM_USAGE_REPORTING=1 opt-in. + from headroom.license_env import resolve_license_token, usage_reporting_enabled + + license_key = resolve_license_token() + usage_reporting = usage_reporting_enabled() # Qdrant connection for the qdrant-neo4j backend. CLI flags default # to None; when omitted we let ProxyConfig's default_factory resolve @@ -1412,7 +1460,8 @@ def proxy( rate_limit_enabled=not no_rate_limit, rate_limit_requests_per_minute=rpm if rpm is not None else 60, rate_limit_tokens_per_minute=tpm, - compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False), + # Same parse as the server entry points: empty means unset (profile). + compress_user_messages=_get_env_optional_bool("HEADROOM_COMPRESS_USER_MESSAGES"), periodic_malloc_trim_enabled=_get_env_bool( "HEADROOM_MALLOC_TRIM", default_periodic_malloc_trim() ), @@ -1478,6 +1527,9 @@ def proxy( if connect_timeout_seconds is not None else 10, write_timeout_seconds=write_timeout_seconds if write_timeout_seconds is not None else 150, + upstream_tcp_keepalive_seconds=( + upstream_tcp_keepalive_seconds if upstream_tcp_keepalive_seconds is not None else 30 + ), anthropic_buffered_request_timeout_seconds=( anthropic_buffered_request_timeout_seconds if anthropic_buffered_request_timeout_seconds is not None @@ -1549,6 +1601,7 @@ def proxy( anyllm_provider=effective_anyllm_provider, # License / Usage Reporting (managed/enterprise) license_key=license_key, + usage_reporting=usage_reporting, # Stateless mode: disable all filesystem writes stateless=is_stateless, # Unit 4: bounded pre-upstream concurrency on the Anthropic HTTP @@ -1574,22 +1627,21 @@ def proxy( if config.memory_enabled: memory_status = "ENABLED (multi-provider)" - license_status = "OSS (no license key)" + license_status = "OSS (no licence)" if license_key: - license_status = f"MANAGED (key={license_key[:8]}...)" + # Never print licence material, not even a prefix. + if not usage_reporting: + reporting = "off" + elif config.offline: + reporting = "suppressed by HEADROOM_OFFLINE" + else: + reporting = "ON" + license_status = f"LICENSED (usage reporting {reporting})" - if config.uds is not None: - # HTTP clients reach a unix socket listener with any Host; the URL is only the request target. - listen_url = "http://localhost" - listen_display = f"unix:{config.uds}" - usage_lines = f" curl: curl --unix-socket {config.uds} {listen_url}/health" - else: - listen_url = f"http://{config.host}:{config.port}" - listen_display = listen_url - usage_lines = ( - f" Claude Code: ANTHROPIC_BASE_URL={listen_url} claude\n" - f" Codex / OpenAI: OPENAI_BASE_URL={listen_url}/v1 your-app" - ) + # HTTP clients reach a unix socket listener with any Host; the URL is only the request target. + listen_url = ( + "http://localhost" if config.uds is not None else f"http://{config.host}:{config.port}" + ) provider_api_targets = resolve_api_targets(config.provider_api_overrides) anthropic_url = provider_api_targets.anthropic @@ -1706,23 +1758,35 @@ def proxy( f"(available: {','.join(_ext_available)})" ) - # Security posture line: inbound auth token + air-gap mode, and a loud - # flag for the open-bind case (non-loopback host with no token). - from headroom.proxy.loopback_guard import is_loopback_host - - _auth_on = bool(config.proxy_token or os.environ.get("HEADROOM_PROXY_TOKEN")) + # Security posture line: inbound auth token + air-gap mode. An open bind + # (non-loopback host, no token) is refused here, before the banner, unless + # the operator acknowledged it explicitly; see headroom/proxy/bind_policy.py. + from headroom.proxy.bind_policy import OPEN_BIND_ACK_ENV, evaluate_bind_policy + + _bind = evaluate_bind_policy(config.bind_policy_host, config.proxy_token) + if _bind.refused: + raise click.ClickException(_bind.message()) + _auth_on = _bind.token_configured + _open_bind_note = ( + f" · WARNING open bind, /v1/* UNAUTHENTICATED (acknowledged via {OPEN_BIND_ACK_ENV}=1)" + if _bind.open_bind + else "" + ) if config.offline: - _security_status = "OFFLINE (all egress disabled)" + ( - " · inbound token REQUIRED (non-loopback)" if _auth_on else "" + # Offline masks nothing: an acknowledged open bind is still an open bind. + _security_status = ( + "OFFLINE (all egress disabled)" + + (" · inbound token REQUIRED (non-loopback)" if _auth_on else "") + + _open_bind_note ) elif _auth_on: _security_status = "inbound token REQUIRED for non-loopback callers" elif config.uds is not None: _security_status = "unix socket (peers limited by filesystem permissions)" - elif not is_loopback_host(config.host): + elif _bind.open_bind: _security_status = ( "WARNING non-loopback bind with NO token — /v1/* is UNAUTHENTICATED " - "(set HEADROOM_PROXY_TOKEN)" + f"(acknowledged via {OPEN_BIND_ACK_ENV}=1; set HEADROOM_PROXY_TOKEN instead)" ) else: _security_status = "loopback-only (no inbound token)" @@ -1748,6 +1812,22 @@ def proxy( else: tuning_section = "" + # A socket has no URL, and no per-agent recipe belongs here — see + # unix_socket.socket_usage_lines() for why the banner stays transport-neutral. + if config.uds is not None: + from headroom.proxy.unix_socket import socket_usage_lines + + listen_display = f"unix:{config.uds}" + usage_section = "\n".join(socket_usage_lines(config.uds)) + else: + listen_display = listen_url + usage_section = "\n".join( + ( + f" Claude Code: ANTHROPIC_BASE_URL={listen_url} claude", + f" Codex / OpenAI: OPENAI_BASE_URL={listen_url}/v1 your-app", + ) + ) + click.echo(f""" ╔═══════════════════════════════════════════════════════════════════════╗ ║ HEADROOM PROXY ║ @@ -1777,7 +1857,7 @@ def proxy( /v1/projects/.../publishers/... → {vertex_url} Usage: -{usage_lines} +{usage_section} {memory_section} Endpoints: GET /livez Process liveness diff --git a/headroom/cli/toin_publish.py b/headroom/cli/toin_publish.py index 89a408bab4..e22559faa3 100644 --- a/headroom/cli/toin_publish.py +++ b/headroom/cli/toin_publish.py @@ -98,6 +98,7 @@ def _toml_escape(s: str) -> str: def _format_row( *, + tenant_key: str, auth_mode: str, model_family: str, structure_hash: str, @@ -110,6 +111,7 @@ def _format_row( skip_flag = "true" if skip_compression_recommended else "false" return ( "[[recommendation]]\n" + f'tenant_key = "{_toml_escape(tenant_key)}"\n' f'auth_mode = "{_toml_escape(auth_mode)}"\n' f'model_family = "{_toml_escape(model_family)}"\n' f'structure_hash = "{_toml_escape(structure_hash)}"\n' @@ -137,7 +139,12 @@ def _eligible_rows( observations = pattern.total_compressions if observations < min_observations: continue - auth_mode, model_family, sig_hash = key + # PR-F3: keys are now 4-tuples + # `(tenant_key, auth_mode, model_family, sig_hash)`. We carry + # `tenant_key` through to the published row so the loaded + # `recommendations.toml` is self-describing — operators can + # filter recommendations per tenant when reviewing them. + tenant_key, auth_mode, model_family, sig_hash = key strategy_hint = ( "skip_compression" if pattern.skip_compression_recommended @@ -145,6 +152,7 @@ def _eligible_rows( ) rows.append( { + "tenant_key": tenant_key, "auth_mode": auth_mode, "model_family": model_family, "structure_hash": sig_hash, @@ -156,7 +164,12 @@ def _eligible_rows( ) rows.sort( - key=lambda r: (r["auth_mode"], r["model_family"], r["structure_hash"]), + key=lambda r: ( + r["tenant_key"], + r["auth_mode"], + r["model_family"], + r["structure_hash"], + ), ) return rows diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index de0836a3c2..0f751073d8 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -11,6 +11,7 @@ headroom wrap vibe # Start proxy + Mistral Vibe headroom wrap grok # Start proxy + Grok CLI headroom wrap cursor # Start proxy + print Cursor config instructions + headroom wrap antigravity # Start proxy + print Antigravity config instructions headroom wrap grok-build # Start proxy + configure Grok Build headroom wrap openclaw # Install + configure OpenClaw plugin headroom wrap claude --port 9999 # Custom proxy port @@ -26,6 +27,7 @@ import json import os import re +import secrets import shutil import signal import socket @@ -39,6 +41,11 @@ from pathlib import Path from typing import Any, NamedTuple, cast +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + import tomli as tomllib + from headroom._subprocess import identity_mismatch as _shared_identity_mismatch from headroom._subprocess import pid_alive, proc_identity, run @@ -76,8 +83,10 @@ resolve_subscription_bearer_token_details, ) from headroom.providers.aider import build_launch_env as _build_aider_launch_env +from headroom.providers.antigravity import ( + render_setup_lines as _render_antigravity_setup_lines, +) from headroom.providers.claude import ( - CONTEXT_1M_SUFFIX, DEFAULT_1M_MODEL, HEADROOM_1M_MODEL_ENV, REMOTE_CONTROL_BASE_URL_ENV, @@ -91,6 +100,7 @@ remote_control_applies_to_auth, remote_control_gate_active, remote_control_gate_message, + remote_control_gate_short_message, remote_control_sibling_gate_note, remove_vscode_claude_settings, resolve_1m_model, @@ -103,7 +113,14 @@ ) from headroom.providers.claude.runtime import TOOL_SEARCH_FOUNDRY_DEFAULT from headroom.providers.codex import build_launch_env as _build_codex_launch_env -from headroom.providers.codex.install import codex_uses_chatgpt_auth +from headroom.providers.codex.install import ( + CodexAuthConfigError, + build_codex_auth_config, + cleanup_codex_auth_helper, + codex_auth_helper_is_referenced, + codex_auth_helper_path, + codex_uses_chatgpt_auth, +) from headroom.providers.codex.threads import retag_to_headroom, retag_to_native from headroom.providers.copilot import ( build_launch_env as _build_copilot_launch_env, @@ -111,6 +128,7 @@ from headroom.providers.copilot import ( configure_vscode_proxy_settings, remove_vscode_proxy_settings, + unrouted_vscode_profiles, vscode_proxy_url, vscode_settings_path, ) @@ -153,6 +171,7 @@ ) from headroom.providers.grok_build import render_setup_lines as _render_grok_build_setup_lines from headroom.providers.grok_build.config import ( + grok_config_paths, inject_grok_provider_config, restore_grok_provider_config, ) @@ -174,9 +193,6 @@ from headroom.providers.openclaw import ( decode_entry_json as _decode_openclaw_entry_json_impl, ) -from headroom.providers.openclaw import ( - normalize_gateway_provider_ids as _normalize_openclaw_gateway_provider_ids_impl, -) from headroom.providers.opencode import build_launch_env as _build_opencode_launch_env from headroom.providers.opencode.config import ( _MCP_MARKER_END, # noqa: F401 @@ -184,10 +200,15 @@ _PROVIDER_MARKER_END, # noqa: F401 _PROVIDER_MARKER_START, inject_opencode_provider_config, + migrate_legacy_opencode_jsonc_backup, opencode_config_paths, snapshot_opencode_config_if_unwrapped, strip_opencode_headroom_blocks, ) +from headroom.providers.opencode.runtime import ( + opencode_major_version, + with_opencode_standalone, +) from headroom.providers.wrap_registry import WRAP_TARGETS, WrapTarget from headroom.providers.wrap_registry import build_launch_env as _build_registry_launch_env from headroom.providers.zcode import ( @@ -307,7 +328,6 @@ def _append_text(path: Path, content: str) -> None: fsutil.append_text(path, content) -_AGENT_SAVINGS_TARGET_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build", "opencode"} _WRAP_PROXY_TIMEOUT_ENV = "HEADROOM_WRAP_PROXY_TIMEOUT" _WRAP_PROXY_TIMEOUT_DEFAULT_SECONDS = 45 _WRAP_PROXY_TIMEOUT_ML_DEFAULT_SECONDS = 90 @@ -323,7 +343,15 @@ def _append_text(path: Path, content: str) -> None: _TOOL_SEARCH_ENV = TOOL_SEARCH_ENV _TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT _TOOL_SEARCH_FOUNDRY_DEFAULT = TOOL_SEARCH_FOUNDRY_DEFAULT -_AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build"} +_AGENT_SAVINGS_WRAP_AGENTS = { + "antigravity", + "claude", + "codex", + "cursor", + "grok", + "grok_build", +} +_CLAUDE_PROJECT_SETTINGS_ENV = "HEADROOM_CLAUDE_PROJECT_SETTINGS" # 1M context window for `wrap claude` (#1158). Claude Code only sends the # `context-1m` beta header — unlocking the 1M window for entitled subscription @@ -333,10 +361,8 @@ def _append_text(path: Path, content: str) -> None: _ANTHROPIC_MODEL_ENV = "ANTHROPIC_MODEL" # Private aliases preserve the standalone wrapper's existing test and import # surface while the provider runtime owns the 1M selection contract. -_CONTEXT_1M_SUFFIX = CONTEXT_1M_SUFFIX _1M_MODEL_ENV = HEADROOM_1M_MODEL_ENV _DEFAULT_1M_MODEL = DEFAULT_1M_MODEL -_OPENCLAUDE_INSTRUCTIONS_FILE = "CONVENTIONS.md" _resolve_1m_model = resolve_1m_model @@ -434,7 +460,10 @@ def _configure_tool_search_env(env: dict[str, str], flag_value: str | None) -> s # Opt out entirely with HEADROOM_WRAP_QUIET=0 (or false/no/off). _QUIET_CLI_ENV = "HEADROOM_WRAP_QUIET" _QUIET_CLI_FALSY = {"0", "false", "no", "off"} -# name -> value, injected only when the user has not already set it. +# name -> value, injected only when the user has not already set it. Every entry +# here suppresses ONLY zero-signal chatter — telemetry pings, version nags, +# funding/first-run banners, progress bars, pager framing — never diffs, errors, +# summaries, or search results. _QUIET_CLI_DEFAULTS: dict[str, str] = { "GIT_PAGER": "cat", # never page (keeps full content, drops pager framing) "PIP_QUIET": "1", # drop "Requirement already satisfied"/download chatter @@ -442,6 +471,20 @@ def _configure_tool_search_env(env: dict[str, str], flag_value: str | None) -> s "npm_config_fund": "false", # drop the funding banner "npm_config_audit": "false", # drop the audit summary (not a security scan here) "npm_config_progress": "false", # drop the install progress bar + "npm_config_update_notifier": "false", # drop the boxed "update available" notice + # Cross-tool telemetry opt-out standard (consoledonottrack.com), honored by + # turbo, netlify, gatsby, and many others to drop telemetry pings/banners. + "DO_NOT_TRACK": "1", + # .NET SDK: telemetry ping + the "Welcome to .NET" logo/copyright banner. + "DOTNET_CLI_TELEMETRY_OPTOUT": "1", + "DOTNET_NOLOGO": "1", + # JS framework telemetry banners printed on build/dev. + "NEXT_TELEMETRY_DISABLED": "1", + "GATSBY_TELEMETRY_DISABLED": "1", + "ASTRO_TELEMETRY_DISABLED": "1", + "NG_CLI_ANALYTICS": "false", # Angular CLI analytics ping + first-run prompt + # Homebrew's "==> ... hints" chatter (not warnings/errors). + "HOMEBREW_NO_ENV_HINTS": "1", } @@ -1065,6 +1108,100 @@ def _code_memory_flag_callback(ctx: Any, param: Any, value: str | None) -> str | ) +# --- Code-memory MCP scope (Claude Code only) -------------------------------- +# Where the code-memory MCP entry is written. Claude Code is the only agent we +# wrap that has scopes: 'project' keeps the entry in ``projects[]`` of +# ~/.claude.json, 'user' puts it in the machine-wide map. Project is the default +# because a code-memory server that fails to start at user scope degrades every +# Claude Code session on the machine, including projects Headroom never touched +# (#2787). Flows through an env var for the same reason as _code_memory_option. +_CODE_MEMORY_SCOPE_ENV = "HEADROOM_CODE_MEMORY_SCOPE" +_CODE_MEMORY_SCOPE_PROJECT = "project" +_CODE_MEMORY_SCOPE_USER = "user" +_VALID_CODE_MEMORY_SCOPES = {_CODE_MEMORY_SCOPE_PROJECT, _CODE_MEMORY_SCOPE_USER} + + +def _resolve_code_memory_scope() -> str: + """Resolve the requested code-memory scope ('project' by default).""" + env = os.environ.get(_CODE_MEMORY_SCOPE_ENV, "").strip().lower() + if env: + if env not in _VALID_CODE_MEMORY_SCOPES: + raise click.ClickException( + f"{_CODE_MEMORY_SCOPE_ENV} must be one of: " + f"{', '.join(sorted(_VALID_CODE_MEMORY_SCOPES))}" + ) + return env + return _CODE_MEMORY_SCOPE_PROJECT + + +def _claude_code_memory_scope() -> str: + """Translate the requested scope into Claude Code's own scope name.""" + from headroom.mcp_registry.claude import SCOPE_LOCAL, SCOPE_USER + + if _resolve_code_memory_scope() == _CODE_MEMORY_SCOPE_USER: + return SCOPE_USER + return SCOPE_LOCAL + + +def _code_memory_scope_flag_callback(ctx: Any, param: Any, value: str | None) -> str | None: + """Click eager callback: mirror ``--code-memory-scope`` into the env var the + resolver reads, so no subcommand signature changes.""" + if value: + os.environ[_CODE_MEMORY_SCOPE_ENV] = value + return value + + +_code_memory_scope_option = click.option( + "--code-memory-scope", + type=click.Choice([_CODE_MEMORY_SCOPE_PROJECT, _CODE_MEMORY_SCOPE_USER]), + default=None, + expose_value=False, + is_eager=True, + callback=_code_memory_scope_flag_callback, + help=( + "Where to register the code-memory MCP: 'project' (default, this " + "directory only) or 'user' (every Claude Code session on the machine). " + "Also set by HEADROOM_CODE_MEMORY_SCOPE." + ), +) + + +def _legacy_code_memory_options(command: Any) -> Any: + """Hidden pre-``--code-memory`` flags, still accepted by claude/codex/grok.""" + for decorator in ( + click.option( + "--no-serena", + is_flag=True, + hidden=True, + help="Deprecated: use --code-memory none. Register no code-memory MCP.", + ), + click.option( + "--serena", + is_flag=True, + hidden=True, + help="Deprecated: use --code-memory serena. Force the Serena MCP compressor on.", + ), + click.option( + "--no-tokensave", + is_flag=True, + hidden=True, + help="Deprecated and ignored: tokensave was retired; Serena is the default code memory.", + ), + ): + command = decorator(command) + return command + + +def _require_binary(*names: str, install_hint: str) -> str: + """Return the first of ``names`` on PATH, or fail with how to install it.""" + for name in names: + found = shutil.which(name) + if found: + return found + shown = f"'{names[0]}'" + "".join(f" (or '{name}')" for name in names[1:]) + raise click.ClickException(f"{shown} not found in PATH.\n{install_hint}") + + # Hook-command markers Headroom manages in Claude settings.json. unwrap drops # any hook entry whose command contains one of these. (Retired rtk / lean-ctx # hooks are removed separately, by @@ -1197,6 +1334,16 @@ def _foundry_proxy_url(proxy_url: str) -> str: return proxy_url.rstrip("/") + "/anthropic" +def _claude_project_settings_enabled(project_settings: bool) -> bool: + """Return whether wrap claude should write project-local Claude settings.""" + env_enabled = _env_bool_value(os.environ.get(_CLAUDE_PROJECT_SETTINGS_ENV, "")) + return project_settings or env_enabled + + +def _claude_project_settings_skip_reason() -> str: + return f"use --project-settings or {_CLAUDE_PROJECT_SETTINGS_ENV}=1 to persist proxy routing" + + def _vertex_target_api_url_from_claude_env(proxy_url: str) -> str | None: """Return the Vertex upstream that the proxy should use for Claude Code.""" explicit_target = os.environ.get("VERTEX_TARGET_API_URL", "").strip() @@ -1283,9 +1430,9 @@ def _locked_file(lock_file: Any) -> Any: import msvcrt # msvcrt.locking operates on bytes from the current file position. - lock_file.seek(0) - if lock_file.read(1) == b"": - lock_file.seek(0) + # Reading the locked byte fails on Windows before a competing + # writer can enter the retry loop. Check metadata instead. + if os.fstat(lock_file.fileno()).st_size == 0: lock_file.write(b"0") lock_file.flush() lock_file.seek(0) @@ -1578,25 +1725,6 @@ def _wrap_proxy_alive(port: int, *, attempts: int = 3, delay: float = 0.25) -> b return False -def _wrap_marker_proxy_is_dead(marker: dict[str, Any]) -> bool: - """True if ``marker`` records a proxy ``port`` that no longer accepts - connections. - - Port liveness is the authoritative signal for a wrap session that vanished - without running its cleanup (hard reboot / SIGKILL, issue #2221): the - recorded PID is unreliable because a reboot can recycle it onto an - unrelated live process, so a PID that still looks alive does not prove the - proxy is up. A marker with no recorded port returns False here (fall back - to PID-based staleness); a marker whose port IS responding is a live - session and must never be treated as dead. Uses the retry-hardened - ``_wrap_proxy_alive`` so a momentary blip never reads as dead. - """ - port = marker.get("port") - if not isinstance(port, int): - return False - return not _wrap_proxy_alive(port) - - def _clear_wrap_marker(settings_path: Path, *, key: str) -> None: marker = _read_wrap_marker(settings_path) if marker is not None and marker.get("key") == key: @@ -1611,16 +1739,41 @@ def _check_and_clear_stale_wrap_marker(settings_path: Path, *, key: str) -> str Called before writing a fresh base_url entry so a crashed wrap session's leftover doesn't get treated as this session's own state to restore later. """ + # Preserve the default no-marker path without creating lock artifacts. marker = _read_wrap_marker(settings_path) if marker is None or marker.get("key") != key or not _wrap_marker_is_stale(marker): return None - previous = marker.get("previous") - click.echo( - f"headroom: clearing stale {key} left by crashed wrap session (pid {marker.get('pid')})", - err=True, - ) - _restore_claude_wrap_base_url(previous, settings_path=settings_path, _key_override=key) - return previous + with _wrap_settings_lock(settings_path): + marker = _read_wrap_marker(settings_path) + if marker is None or marker.get("key") != key or not _wrap_marker_is_stale(marker): + return None + port = marker.get("port") + if not isinstance(port, int) or isinstance(port, bool): + return None + expected_url = f"http://127.0.0.1:{port}" + if key == "ANTHROPIC_FOUNDRY_BASE_URL": + expected_url = _foundry_proxy_url(expected_url) + try: + settings = json.loads(_read_text(settings_path)) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + env_settings = settings.get("env") if isinstance(settings, dict) else None + current_url = env_settings.get(key) if isinstance(env_settings, dict) else None + if current_url != expected_url: + # The user (or a newer session) has replaced the crashed writer's URL. + # Retire its stale marker without changing the current settings. + if _read_wrap_marker(settings_path) == marker: + _clear_wrap_marker(settings_path, key=key) + return None + previous = marker.get("previous") + click.echo( + f"headroom: clearing stale {key} left by crashed wrap session (pid {marker.get('pid')})", + err=True, + ) + _restore_claude_wrap_base_url( + previous, settings_path=settings_path, _key_override=key, _lock_held=True + ) + return previous def _check_and_clear_dead_wrap_marker(settings_path: Path, *, key: str) -> str | None: @@ -1924,6 +2077,7 @@ def _restore_claude_wrap_base_url( _key_override: str | None = None, force: bool = False, dead_ports: frozenset[int] = frozenset(), + _lock_held: bool = False, ) -> None: """Restore (or remove) the env key written by _write_claude_wrap_base_url. @@ -1939,11 +2093,13 @@ def _restore_claude_wrap_base_url( (``unwrap``), and ``dead_ports`` to name proxy ports already proven dead so holders that outlived their proxy stop counting as live. """ + from contextlib import nullcontext + path = settings_path or (Path.cwd() / ".claude" / "settings.local.json") key = _key_override or _claude_wrap_base_url_env_key( foundry_mode=foundry_mode, vertex_mode=vertex_mode ) - with _wrap_settings_lock(path): + with nullcontext() if _lock_held else _wrap_settings_lock(path): # Another live wrap session in this project may still be using the key. # Restoring underneath it silently unroutes a running session -- traffic # bypasses the proxy with no error anywhere (#3205). @@ -2455,6 +2611,8 @@ def _setup_serena_mcp( from headroom.mcp_registry.base import RegisterStatus from headroom.mcp_registry.ledger import headroom_installed_matching, record_install + ownership_key = getattr(registrar, "ownership_key", lambda name: name)("serena") + if not registrar.detect(): if verbose: click.echo(f" Serena MCP: {registrar.display_name} not detected — skipping") @@ -2472,7 +2630,9 @@ def _setup_serena_mcp( owned_drift = ( result.status == RegisterStatus.MISMATCH and not force - and headroom_installed_matching(registrar.name, registrar.get_server("serena")) + and headroom_installed_matching( + registrar.name, registrar.get_server("serena"), ownership_key=ownership_key + ) ) # Migrate a stale Headroom-installed entry. register_server won't overwrite @@ -2485,8 +2645,15 @@ def _setup_serena_mcp( if result.status == RegisterStatus.REGISTERED: click.echo(" Serena MCP: migrated previously-installed entry to current spec") + # Retire the machine-wide entry once the project-scoped one is in place. + # Must run before record_install below: the ledger still holds the + # fingerprint of the entry that was installed globally, which is how we + # recognise it as ours. + if result.ok: + _retire_user_scope_serena(registrar) + if result.status == RegisterStatus.REGISTERED: - record_install(registrar.name, spec) + record_install(registrar.name, spec, ownership_key=ownership_key) line = format_result( registrar.name, @@ -2505,6 +2672,11 @@ def _setup_serena_mcp( if line is not None: click.echo(line) + # Say where it landed, so the scope is discoverable without reading docs. + # Only on an actual write (or in verbose): re-wraps should stay quiet. + if result.status == RegisterStatus.REGISTERED or verbose: + _echo_serena_scope_note(registrar) + # 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, and neither blocks the launch: the pre-index @@ -2530,10 +2702,90 @@ def _setup_serena_mcp( _index_serena_project(verbose=verbose) +def _echo_serena_scope_note(registrar: Any) -> None: + """Tell the user which sessions the Serena entry will load in.""" + from headroom.mcp_registry.claude import SCOPE_LOCAL, SCOPE_USER + + scope = getattr(registrar, "scope", None) + if scope == SCOPE_LOCAL: + click.echo( + " scoped to this project — pass --code-memory-scope user to register it " + "for every Claude Code session" + ) + elif scope == SCOPE_USER: + click.echo( + " registered for every Claude Code session on this machine " + "(--code-memory-scope user)" + ) + + +def _retire_user_scope_serena(registrar: Any) -> None: + """Remove a machine-wide Serena entry left by a pre-#2787 wrap. + + Headroom used to register Serena at Claude Code's user scope, where a + Serena that fails to start breaks *every* Claude Code session on the + machine — including projects that were never wrapped (#2783). Now that the + entry is written per project, the global one is redundant, so an upgrading + user gets it cleaned up on their next wrap instead of carrying the blast + radius forever. + + Only entries the ledger proves Headroom installed are removed; a Serena the + user registered globally themselves is left exactly where it is. No-ops for + registrars without scopes (Codex, Grok, OpenCode) and when the user asked + for user scope explicitly. + """ + from headroom.mcp_registry.claude import SCOPE_LOCAL, SCOPE_USER + from headroom.mcp_registry.ledger import clear_install, headroom_installed_matching + + if getattr(registrar, "scope", None) != SCOPE_LOCAL: + return + try: + global_entry = registrar.get_server("serena", scope=SCOPE_USER) + except TypeError: # registrar predates scoped reads + return + if not headroom_installed_matching(registrar.name, global_entry): + return + if registrar.unregister_server("serena", scope=SCOPE_USER): + # Clear the legacy ledger record along with the config entry it + # authorized — otherwise its fingerprint outlives the migration and + # can later be mistaken for proof that Headroom owns a same-named + # entry the user installs globally themselves. + clear_install(registrar.name, "serena") + click.echo( + " Serena MCP: removed the machine-wide entry an earlier wrap installed " + "(it now loads only in this project)" + ) + + def _remove_headroom_installed_serena_mcp(registrar: Any) -> str: """Remove Serena MCP only if the ledger proves Headroom installed it.""" from headroom.mcp_registry.ledger import clear_install, headroom_installed_matching + # Claude can own one Serena entry per project plus a legacy user entry. + # Examine each relevant scope independently: an unscoped delete would + # remove entries whose scope-specific ledger record did not authorize it. + if hasattr(registrar, "ownership_key"): + from headroom.mcp_registry.claude import SCOPE_LOCAL, SCOPE_USER + + found_owned = False + for scope in (SCOPE_LOCAL, SCOPE_USER): + current = registrar.get_server("serena", scope=scope) + ownership_key = registrar.ownership_key("serena", scope=scope) + if current is None: + # Nothing left to protect — an ownership record must not + # outlive the entry it authorized. + clear_install(registrar.name, "serena", ownership_key=ownership_key) + continue + if not headroom_installed_matching( + registrar.name, current, ownership_key=ownership_key + ): + continue + found_owned = True + if not registrar.unregister_server("serena", scope=scope): + return "failed" + clear_install(registrar.name, "serena", ownership_key=ownership_key) + return "removed" if found_owned else "not_headroom_owned" + current = registrar.get_server("serena") if not headroom_installed_matching(registrar.name, current): return "not_headroom_owned" @@ -2591,10 +2843,21 @@ def _remove_headroom_installed_tokensave_mcp(registrar: Any) -> str: """Remove the tokensave MCP entry only if the ledger proves Headroom installed it.""" from headroom.mcp_registry.ledger import clear_install, headroom_installed_matching - current = registrar.get_server("tokensave") + # Headroom only ever installed tokensave machine-wide: it was retired before + # #2787 introduced project scope, so its ledger key is the pre-scope (user + # scope) one. Pin the ownership check and the delete to that scope — an + # unscoped delete now also takes the project entry, which no ledger record + # authorizes, and an unscoped read lets a project entry shadow ours. + at_user_scope: dict[str, Any] = {} + if hasattr(registrar, "ownership_key"): # Claude; the others have no scopes + from headroom.mcp_registry.claude import SCOPE_USER + + at_user_scope = {"scope": SCOPE_USER} + + current = registrar.get_server("tokensave", **at_user_scope) if not headroom_installed_matching(registrar.name, current): return "not_headroom_owned" - if registrar.unregister_server("tokensave"): + if registrar.unregister_server("tokensave", **at_user_scope): clear_install(registrar.name, "tokensave") return "removed" return "failed" @@ -2745,6 +3008,13 @@ def _codex_dotted_key(*parts: str) -> str: ) +_CODEX_EMBEDDED_MODE_NOTE = ( + "Note: session-scoped routing runs Codex without its shared background server " + "(embedded mode). To keep the shared server, route through Codex config instead: " + "run `headroom init codex` (or `headroom init -g codex`) once, then start `codex` directly." +) + + def _codex_session_launch_settings( *, port: int, codex_args: tuple[str, ...], environ: dict[str, str] ) -> tuple[tuple[str, ...], dict[str, str], list[str]]: @@ -2804,6 +3074,11 @@ def _codex_session_launch_settings( if project and "HEADROOM_PROJECT" not in env: env["HEADROOM_PROJECT"] = project + # Routing is passed as ``--config`` so it stays scoped to this process, and + # Codex runs any session with command-line overrides in embedded mode, + # without its shared background server (#3888). Say so up front, and point + # at the persistent alternative that leaves the shared server in play. + display.append(_CODEX_EMBEDDED_MODE_NOTE) config_args = tuple(item for override in overrides for item in ("--config", override)) return (*config_args, *codex_args), env, display @@ -2884,24 +3159,29 @@ def _remove_marker_span(text: str, start_marker: str, end_marker: str) -> str: content = _remove_marker_span(content, _MEMORY_MCP_MARKER, _MEMORY_MCP_END) # Strip any leftover top-level keys that older (or crashed) versions of - # `wrap codex` may have written outside the marker block. - content = re.sub(r'(?m)^[ \t]*model_provider[ \t]*=[ \t]*"headroom"[ \t]*\r?\n', "", content) - content = re.sub( - r'(?m)^[ \t]*openai_base_url[ \t]*=[ \t]*"http://127\.0\.0\.1:\d+/v1"[ \t]*\r?\n', - "", - content, - ) + # `wrap codex` may have written outside the marker block. Root only: + # [profiles.*] overrides are the user's. + from headroom.cli.init import _strip_codex_root_routing_orphans - # Strip any orphaned `[model_providers.headroom]` table with the fields we - # write. We only remove it if the table is recognisably ours (base_url - # mentions localhost and a Headroom proxy port). This protects users who - # happen to have a differently configured `headroom` provider. - orphan_headroom_table = re.compile( - r"(?ms)^\[model_providers\.headroom\][^\[]*?" - r'base_url[ \t]*=[ \t]*"http://127\.0\.0\.1:\d+/v1"[^\[]*?' - r"(?=^\[|\Z)" - ) - content = orphan_headroom_table.sub("", content) + content = _strip_codex_root_routing_orphans(content) + + # Remove an orphaned local-proxy provider structurally. Text-level table + # matching cannot safely distinguish comments, multiline strings, and + # arrays of tables from a provider's body. + import tomlkit + + try: + document = tomlkit.parse(content) + except tomlkit.exceptions.ParseError: + pass + else: + providers = document.get("model_providers") + if isinstance(providers, MutableMapping): + provider = providers.get("headroom") + base_url = provider.get("base_url") if isinstance(provider, Mapping) else None + if isinstance(base_url, str) and re.fullmatch(r"http://127\.0\.0\.1:\d+/v1", base_url): + del providers["headroom"] + content = tomlkit.dumps(document) return content.lstrip("\n").rstrip() + "\n" if content.strip() else "" @@ -2915,17 +3195,51 @@ def _remove_marker_span(text: str, start_marker: str, end_marker: str) -> str: def _strip_existing_codex_headroom_provider_table(content: str) -> str: - """Remove a pre-existing ``[model_providers.headroom]`` table before wrap.""" - if "[model_providers.headroom]" not in content: + """Remove a legacy Headroom provider table without touching other TOML. + + ``tomlkit`` identifies tables structurally, preserving comments, string + values, and arrays of tables that text-level table-boundary matching can + mistake for provider content. The injector owns this provider name, so an + existing table is replaced by the current Headroom configuration. + """ + import tomlkit + + try: + document = tomlkit.parse(content) + except tomlkit.exceptions.ParseError: return content - import re # local import to match surrounding helper convention + providers = document.get("model_providers") + if not isinstance(providers, MutableMapping): + return content + provider = providers.get("headroom") + if not isinstance(provider, Mapping): + return content + del providers["headroom"] + return str(tomlkit.dumps(document)) - provider_table = re.compile( - r"(?ms)^[ \t]*\[model_providers\.headroom\][^\n]*\n.*?(?=^[ \t]*\[|\Z)" - ) - content = provider_table.sub("", content) - return content.lstrip("\n").rstrip() + "\n" if content.strip() else "" + +def _strip_existing_codex_memory_mcp_table(content: str) -> str: + """Remove an unmarked memory MCP table without text-level TOML parsing. + + TOMLKit resolves quoted keys and finds table boundaries structurally, so + comments, multiline strings, and arrays of tables remain untouched. + Invalid source TOML is deliberately left unchanged; the caller validates + its complete generated candidate before it can overwrite the file. + """ + import tomlkit + + try: + document = tomlkit.parse(content) + except tomlkit.exceptions.ParseError: + return content + + mcp_servers = document.get("mcp_servers") + if not isinstance(mcp_servers, MutableMapping) or "headroom_memory" not in mcp_servers: + return content + + del mcp_servers["headroom_memory"] + return str(tomlkit.dumps(document)) def _redirect_existing_top_level_keys(content: str, port: int) -> str: @@ -3091,6 +3405,24 @@ def _apply_project_header_env(env: dict[str, str]) -> None: ) +# Read by proxy's workspace_registry.resolve_registered_cwd() to look up +# this session's registered cwd. +_SESSION_TOKEN_HEADER_NAME = "X-Headroom-Session-Token" + + +def _apply_session_token_header_env(env: dict[str, str], session_token: str) -> None: + """Inject X-Headroom-Session-Token into ``ANTHROPIC_CUSTOM_HEADERS``. + + Mirrors :func:`_apply_project_header_env`. No "user override wins" case + here -- the token is wrap-generated, not user-supplied. + """ + if not session_token: + return + header_line = f"{_SESSION_TOKEN_HEADER_NAME}: {session_token}" + existing = env.get("ANTHROPIC_CUSTOM_HEADERS") + env["ANTHROPIC_CUSTOM_HEADERS"] = f"{existing}\n{header_line}" if existing else header_line + + # Codex's own built-in providers plus Headroom's injected one — never treated # as a "custom upstream to preserve" by _detect_custom_codex_upstream_base_url. _CODEX_BUILTIN_PROVIDER_NAMES = frozenset({"openai", "anthropic", "azure", "headroom"}) @@ -3252,6 +3584,10 @@ def _inject_codex_provider_config(port: int) -> str | None: requires_openai_auth = ( "requires_openai_auth = true\n" if codex_uses_chatgpt_auth(config_dir / "auth.json") else "" ) + try: + auth_config = build_codex_auth_config(config_dir / "auth.json", config_path=config_file) + except CodexAuthConfigError as exc: + raise click.ClickException(str(exc)) from exc # Per-project savings: Codex sends the X-Headroom-Project header only # when the mapped env var (HEADROOM_PROJECT, set by `headroom wrap # codex`) exists at Codex runtime. When a custom upstream was detected, @@ -3267,6 +3603,7 @@ def _inject_codex_provider_config(port: int) -> str | None: 'name = "OpenAI via Headroom proxy"\n' f'base_url = "http://127.0.0.1:{port}/v1"\n' f"supports_websockets = true\n" + f"{auth_config}" f"{requires_openai_auth}" # Inline table keeps the key inside this section so # _strip_codex_headroom_blocks removes it with the rest of the block. @@ -3305,12 +3642,31 @@ def _build_top_level_block(user_content: str) -> str: _snapshot_codex_config_if_unwrapped(config_file, backup_file) if config_file.exists(): - content = _read_text(config_file) + original = _read_text(config_file) # Remove any prior Headroom-managed blocks before re-injecting so # the operation is idempotent and supports port changes. - content = _strip_codex_headroom_blocks(content) + content = _strip_codex_headroom_blocks(original) content = _strip_existing_codex_headroom_provider_table(content) + # Report a broken file in the user's own terms. The candidate + # validated below is the merged text, so its parse error would + # name a line the user never wrote. Only the user's part has to + # parse: stripping a stale Headroom block may be what repairs it. + try: + tomllib.loads(content) + except tomllib.TOMLDecodeError: + try: + tomllib.loads(original) + reason = "it is no longer valid TOML once Headroom's block is removed" + except tomllib.TOMLDecodeError as exc: + reason = f"it is not valid TOML ({exc})" + click.echo( + f" Warning: could not update Codex config {config_file}: {reason}; " + "Codex WebSocket traffic will bypass Headroom. Fix or remove the file, " + "then re-run." + ) + return None + # Bare top-level keys must precede any [section] in TOML, and # TOML rejects duplicate top-level keys. Rewrite any existing # top-level ``model_provider`` / ``openai_base_url`` in place @@ -3347,6 +3703,8 @@ def _build_top_level_block(user_content: str) -> str: f"\n{provider_section}" ) + # Never replace a usable config with a malformed generated candidate. + tomllib.loads(content) _write_text(config_file, content) click.echo(f" Codex config: injected Headroom provider (WS + HTTP) into {config_file}") if custom_upstream_base_url: @@ -3359,14 +3717,15 @@ def _build_top_level_block(user_content: str) -> str: # history list stays whole once it routes through Headroom. Best-effort. retag_to_headroom(_codex_home_dir()) except Exception as e: - click.echo(f" Warning: could not update Codex config: {e}") + click.echo(f" Warning: could not update Codex config {config_file}: {e}") return None return custom_upstream_base_url def _restore_codex_provider_config() -> tuple[str, Path]: - """Undo ``_inject_codex_provider_config`` for the active Codex config file. + """Undo ``_inject_codex_provider_config`` (and ``headroom init codex`` + routing) for the active Codex config file. Returns a tuple of ``(status, config_file)`` where status is one of: @@ -3375,21 +3734,61 @@ def _restore_codex_provider_config() -> tuple[str, Path]: * ``"cleaned"`` — no backup existed, but the Headroom-managed block was found and stripped out (preserving surrounding user content). * ``"removed"`` — the config file only contained Headroom-managed - content (created by wrap) and has been deleted. + content (created by wrap or init) and has been deleted. * ``"noop"`` — nothing to undo; no Headroom marker and no backup. """ + from headroom.cli.init import ( + _CODEX_PROVIDER_MARKER_START, + _codex_init_provider_snapshot, + _strip_codex_init_block, + ) + config_file, backup_file = _codex_config_paths() + helper_auth_path = config_file.parent / "auth.json" + helper_path = codex_auth_helper_path(helper_auth_path, config_path=config_file) # Case 1: pre-wrap snapshot exists — restore it exactly. if backup_file.exists(): - shutil.copy2(backup_file, config_file) + try: + helper_was_preexisting = ( + codex_auth_helper_is_referenced(_read_text(backup_file), str(helper_path.resolve())) + is not False + ) + except OSError: + # If the backup cannot be inspected, preserve the helper rather than + # risk deleting a file that predates this wrap. + helper_was_preexisting = True + # A snapshot taken after `headroom init codex` still carries init's + # routing block; restoring it verbatim would leave Codex pinned to the + # proxy while unwrap reports success (#3749). The snapshot is deleted + # only once the config is written, so a failed write can be retried. + snapshot = _read_text(backup_file) + if _CODEX_PROVIDER_MARKER_START in snapshot: + cleaned = _strip_codex_init_block(snapshot) + if not cleaned.strip(): + config_file.unlink(missing_ok=True) + backup_file.unlink() + cleanup_codex_auth_helper(helper_auth_path, config_path=config_file) + return "removed", config_file + _write_text(config_file, cleaned) + # Init's helper may predate wrap but its only reference was just + # removed. Cleanup checks all retained providers before deleting it. + cleanup_codex_auth_helper(helper_auth_path, config_path=config_file) + else: + shutil.copy2(backup_file, config_file) backup_file.unlink() + if not helper_was_preexisting: + cleanup_codex_auth_helper(helper_auth_path, config_path=config_file) return "restored", config_file # Case 2: no backup, but config file exists and has markers — strip them. if config_file.exists(): original = _read_text(config_file) - if _codex_config_has_headroom_markers(original): + has_init_block = _CODEX_PROVIDER_MARKER_START in original + if has_init_block or _codex_config_has_headroom_markers(original): + helper_was_referenced = codex_auth_helper_is_referenced( + original, str(helper_path.resolve()) + ) # Without a backup, only remove named MCP blocks when this file # also carries wrap-owned provider markers from a full wrap. remove_named_mcp = any( @@ -3401,17 +3800,31 @@ def _restore_codex_provider_config() -> tuple[str, Path]: _CODEX_MCP_END, ) ) + # `headroom init codex` writes its own routing block, and nothing else + # removes it (#3749). Strip it first so its markers go with its keys. + provider_snapshot = _codex_init_provider_snapshot(original) if has_init_block else None + content = ( + _strip_codex_init_block(original, restore_provider=False) + if has_init_block + else original + ) cleaned = _strip_codex_headroom_blocks( - original, + content, remove_mcp=True, remove_named_mcp=remove_named_mcp, ) + if provider_snapshot is not None: + cleaned = cleaned.rstrip() + "\n\n" + provider_snapshot if not cleaned.strip(): # Nothing left but Headroom content — remove the file entirely # so Codex falls back to its default config. config_file.unlink() + if helper_was_referenced is True: + cleanup_codex_auth_helper(helper_auth_path, config_path=config_file) return "removed", config_file _write_text(config_file, cleaned) + if helper_was_referenced is True: + cleanup_codex_auth_helper(helper_auth_path, config_path=config_file) return "cleaned", config_file # Nothing to undo. @@ -3574,10 +3987,21 @@ def _inject_memory_mcp_config(user_id: str) -> None: end = content.index(_MEMORY_MCP_END) + len(_MEMORY_MCP_END) content = content[:start].rstrip("\n") + mcp_section + content[end:].lstrip("\n") else: + # No marker yet. A `[mcp_servers.headroom_memory]` table can + # still exist unmarked — e.g. a user copied the snippet from + # this module's docstring by hand, or the table survived + # from before markers guarded this injection. Strip it first + # so we don't end up with two `[mcp_servers.headroom_memory]` + # tables, which is invalid TOML and silently breaks Codex's + # MCP config parsing (last-table-wins or hard failure + # depending on the TOML parser). + content = _strip_existing_codex_memory_mcp_table(content) content = content.rstrip() + "\n" + mcp_section else: content = mcp_section + # Never replace a usable config with a malformed generated candidate. + tomllib.loads(content) _write_text(config_file, content) click.echo(f" Memory MCP: registered in {config_file}") except Exception as e: @@ -3667,76 +4091,6 @@ def _env_bool_value(value: str) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} -def _agent_savings_config_mismatches( - running_config: dict[str, Any], - agent_type: str, -) -> list[str]: - """Return restart reasons when a running proxy lacks target agent savings.""" - - if agent_type not in _AGENT_SAVINGS_TARGET_AGENTS: - return [] - - if _wrap_agent_savings_profile(agent_type) is None: - return [] - - desired_env = os.environ.copy() - apply_agent_savings_env_defaults(desired_env) - checks: tuple[tuple[str, str, str, str], ...] = ( - ("HEADROOM_SAVINGS_PROFILE", "savings_profile", "savings-profile", "str"), - ("HEADROOM_TARGET_RATIO", "target_ratio", "target-ratio", "float"), - ( - "HEADROOM_COMPRESS_USER_MESSAGES", - "compress_user_messages", - "compress-user-messages", - "bool", - ), - ( - "HEADROOM_COMPRESS_SYSTEM_MESSAGES", - "compress_system_messages", - "compress-system-messages", - "bool", - ), - ("HEADROOM_PROTECT_RECENT", "protect_recent", "protect-recent", "int"), - ( - "HEADROOM_PROTECT_ANALYSIS_CONTEXT", - "protect_analysis_context", - "protect-analysis-context", - "bool", - ), - ("HEADROOM_MIN_TOKENS", "min_tokens_to_crush", "min-tokens", "int"), - ("HEADROOM_MAX_ITEMS", "max_items_after_crush", "max-items", "int"), - ( - "HEADROOM_SMART_CRUSHER_COMPACTION", - "smart_crusher_with_compaction", - "smart-crusher-compaction", - "bool", - ), - ("HEADROOM_ACCURACY_GUARD", "accuracy_guard", "accuracy-guard", "str"), - ) - - mismatches: list[str] = [] - for env_key, config_key, label, value_type in checks: - expected = desired_env.get(env_key) - if expected is None: - continue - actual = running_config.get(config_key) - try: - if value_type == "float": - matches = actual is not None and abs(float(actual) - float(expected)) < 1e-9 - elif value_type == "int": - matches = actual is not None and int(actual) == int(expected) - elif value_type == "bool": - matches = actual is not None and bool(actual) is _env_bool_value(expected) - else: - matches = str(actual or "").strip().lower() == expected.strip().lower() - except (TypeError, ValueError): - matches = False - if not matches: - mismatches.append(label) - - return mismatches - - def _proxy_active_session_count(payload: dict[str, Any] | None) -> int: """Return active session count from /health runtime metadata.""" if payload is None: @@ -3765,6 +4119,48 @@ def _normalize_proxy_api_url(url: object, *, strip_provider_v1: bool = True) -> return normalized or None +def _require_no_proxy_openai_upstream(port: int, openai_api_url: str) -> dict[str, Any]: + """Refuse ``--no-proxy`` unless the proxy on ``port`` already targets ``openai_api_url``. + + ``--no-proxy`` reuses a listener wrap does not own, so it can neither + start nor re-point one. Reusing it unchecked would present a third-party + key (DeepSeek, Together, ...) to whatever upstream that proxy was started + with — usually OpenAI, which rejects it with a 401 — while the flag + implies the key went to the requested provider (#3107). Fail closed + instead: require a healthy Headroom listener whose advertised upstream + matches exactly after normalization. + """ + helpers = _live_wrap_module() + start_cmd = f"headroom proxy --port {port} --openai-api-url {openai_api_url}" + if not helpers._check_proxy(port): + raise click.ClickException( + f"No Headroom proxy is listening on port {port}, so --no-proxy cannot honor " + f"the requested OpenAI-compatible upstream {openai_api_url}. " + f"Start it separately with `{start_cmd}`, or drop --no-proxy so wrap can start it." + ) + running_config: dict[str, Any] | None = helpers._proxy_health_config( + helpers._query_proxy_health(port) + ) + if running_config is None: + running_config = helpers._query_proxy_config(port) + if running_config is None: + raise click.ClickException( + f"The listener on port {port} did not report a Headroom config, so wrap " + f"cannot confirm it forwards to {openai_api_url}. " + f"Start a Headroom proxy separately with `{start_cmd}`, or drop --no-proxy " + "so wrap can start it." + ) + running_url = running_config.get("openai_api_url") + if _normalize_proxy_api_url(running_url) != _normalize_proxy_api_url(openai_api_url): + raise click.ClickException( + f"The Headroom proxy on port {port} forwards OpenAI-compatible traffic to " + f"{running_url or 'https://api.openai.com/v1'}, not {openai_api_url}. " + f"Restart it with `{start_cmd}`, or drop --no-proxy so wrap can restart it " + "when no other wrapper is attached." + ) + return running_config + + def _proxy_version(payload: dict[str, Any] | None) -> str | None: """Return the running proxy version when it exposes one.""" if payload is None: @@ -3776,9 +4172,16 @@ def _proxy_version(payload: dict[str, Any] | None) -> str | None: def _proxy_needs_version_restart(payload: dict[str, Any] | None) -> bool: """Return True when a running Headroom proxy uses a different package version.""" running_version = _proxy_version(payload) - running_release = _normalize_release_version(running_version) # -dev is a display marker for source builds; compare the base release so a - # dev CLI still restarts a stale proxy on a real version difference. + # dev CLI still restarts a stale proxy on a real version difference. The + # suffix must be stripped from the running version too: with it intact, + # "0.34.0-dev" fails the release regex, normalizes to None, and the check + # below silently never fires for source-built proxies. The scheme is always + # "-dev" (get_version in headroom/_version.py); any future "-rcN" or + # "-dev.N" style would need stripping here as well. + running_release = _normalize_release_version( + running_version.removesuffix("-dev") if running_version is not None else None + ) current_release = _normalize_release_version(_HEADROOM_VERSION.removesuffix("-dev")) return ( running_release is not None @@ -4135,20 +4538,28 @@ def _find_persistent_manifest(port: int) -> Any: def _recover_persistent_proxy(port: int) -> bool: """Start or recover a matching persistent deployment for the requested port.""" - from headroom.install.health import probe_ready - from headroom.install.models import InstallPreset, SupervisorKind - from headroom.install.runtime import start_detached_agent, start_persistent_docker, wait_ready + from headroom.install.models import SupervisorKind + from headroom.install.runtime import ( + runtime_ownership, + runtime_ready, + start_detached_agent, + start_persistent_docker, + wait_ready, + ) from headroom.install.supervisors import start_supervisor manifest = _find_persistent_manifest(port) if manifest is None: return False - if probe_ready(manifest.health_url): + ownership = runtime_ownership(manifest) + if runtime_ready(manifest): click.echo(f" Reusing persistent deployment '{manifest.profile}' on port {port}") return True - if manifest.supervisor_kind == SupervisorKind.TASK.value: + if ownership == "docker-supervisor": + click.echo(f" Recovering persistent deployment '{manifest.profile}' on port {port}...") + elif manifest.supervisor_kind == SupervisorKind.TASK.value: click.echo( f" Warning: task-based deployment '{manifest.profile}' cannot be auto-recovered via wrap" ) @@ -4156,7 +4567,7 @@ def _recover_persistent_proxy(port: int) -> bool: click.echo(f" Recovering persistent deployment '{manifest.profile}' on port {port}...") try: - if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + if ownership == "docker-supervisor": start_persistent_docker(manifest) elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: start_supervisor(manifest) @@ -4168,7 +4579,7 @@ def _recover_persistent_proxy(port: int) -> bool: ) return False - if wait_ready(manifest, timeout_seconds=45): + if wait_ready(manifest, timeout_seconds=45, require_identity=True): click.echo(f" Recovered persistent deployment '{manifest.profile}' on port {port}") return True @@ -4178,8 +4589,9 @@ def _recover_persistent_proxy(port: int) -> bool: def _restart_persistent_proxy(manifest: Any, port: int) -> bool: """Restart a persistent deployment after an idle stale-version detection.""" - from headroom.install.models import InstallPreset, SupervisorKind + from headroom.install.models import SupervisorKind from headroom.install.runtime import ( + runtime_ownership, start_detached_agent, start_persistent_docker, stop_runtime, @@ -4192,7 +4604,7 @@ def _restart_persistent_proxy(manifest: Any, port: int) -> bool: f"with Headroom {_HEADROOM_VERSION}..." ) try: - if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + if runtime_ownership(manifest) == "docker-supervisor": stop_runtime(manifest) start_persistent_docker(manifest) elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: @@ -4208,7 +4620,7 @@ def _restart_persistent_proxy(manifest: Any, port: int) -> bool: ) return False - if wait_ready(manifest, timeout_seconds=45): + if wait_ready(manifest, timeout_seconds=45, require_identity=True): click.echo(f" Restarted persistent deployment '{manifest.profile}' on port {port}") return True @@ -4317,6 +4729,7 @@ def _ensure_proxy_unlocked( anyllm_provider: str | None = None, region: str | None = None, openai_api_url: str | None = None, + require_openai_api_url: bool = False, anthropic_api_url: str | None = None, vertex_api_url: str | None = None, clear_vertex_api_url: bool = False, @@ -4380,9 +4793,9 @@ def _ensure_proxy_unlocked( ) manifest = None if not isolated_copilot_subscription_proxy and manifest is not None: - from headroom.install.health import probe_ready + from headroom.install.runtime import runtime_ready - if probe_ready(manifest.health_url): + if runtime_ready(manifest): health_payload = helpers._query_proxy_health(port) running_config = helpers._proxy_health_config(health_payload) if running_config is None: @@ -4429,8 +4842,11 @@ def _ensure_proxy_unlocked( f" Leaving it running because {detail} " "are still attached; it will be restarted when idle." ) + _warn_proxy_mode_mismatch(running_config) return None, port if helpers._restart_persistent_proxy(manifest, port): + # Restarted from the manifest, whose mode need not be ours. + _warn_proxy_mode_mismatch(helpers._query_proxy_config(port)) return None, port raise click.ClickException( f"Persistent deployment '{manifest.profile}' on port {port} " @@ -4446,8 +4862,8 @@ def _ensure_proxy_unlocked( if code_graph and not running_config.get("code_graph"): missing.append("code_graph") if not missing: - click.echo(f" Proxy already running on port {port}") - click.echo(f" Dashboard: http://127.0.0.1:{port}/dashboard") + click.echo(_proxy_status_line("Proxy already running", port)) + _warn_proxy_mode_mismatch(running_config) return None, port # Features mismatch or config unavailable — fall through to the # non-persistent path which handles proxy restart. A routing @@ -4493,6 +4909,7 @@ def _ensure_proxy_unlocked( missing.append("code-graph") if not missing: + _warn_proxy_mode_mismatch(running_config) return None, port flags_str = ", ".join(f"--{f}" for f in missing) click.echo( @@ -4500,6 +4917,8 @@ def _ensure_proxy_unlocked( f"is missing: {flags_str}; restarting..." ) if helpers._restart_persistent_proxy(manifest, port): + # Restarted from the manifest, whose mode need not be ours. + _warn_proxy_mode_mismatch(helpers._query_proxy_config(port)) return None, port raise click.ClickException( f"Persistent deployment '{manifest.profile}' on port {port} " @@ -4599,6 +5018,7 @@ def _ensure_proxy_unlocked( f" Leaving it running because {detail} " "are still attached; it will be restarted when idle." ) + _warn_proxy_mode_mismatch(running_config) return None, port else: @@ -4677,6 +5097,7 @@ def _ensure_proxy_unlocked( f" Please stop the proxy on port {port} manually " f"and rerun with {flags_str}." ) + _warn_proxy_mode_mismatch(running_config) return None, port # Routing-level config (backend, upstream URLs) must match in @@ -4720,8 +5141,8 @@ def _ensure_proxy_unlocked( needs_restart = True if not needs_restart and reuse_running: - click.echo(f" Proxy already running on port {port}") - click.echo(f" Dashboard: http://127.0.0.1:{port}/dashboard") + click.echo(_proxy_status_line("Proxy already running", port)) + _warn_proxy_mode_mismatch(running_config) return None, port # Start (or restart) the proxy with the requested flags. @@ -4767,32 +5188,45 @@ def _ensure_proxy_unlocked( copilot_api_token_expires_at=copilot_api_token_expires_at, ), ) - click.echo(f" Proxy ready on http://127.0.0.1:{actual_port}") - click.echo(f" Dashboard: http://127.0.0.1:{actual_port}/dashboard") + click.echo(_proxy_status_line("Proxy ready", actual_port)) return proc, actual_port except RuntimeError as e: click.echo(f" Error: {e}") raise SystemExit(1) from e else: - if not helpers._check_proxy(port): + if require_openai_api_url and openai_api_url: + # A user-chosen upstream cannot be applied to a proxy wrap does + # not own; fail closed unless the running one already matches. + running_config = _require_no_proxy_openai_upstream(port, openai_api_url) + click.echo(f" Proxy on port {port} already targets {openai_api_url}") + _warn_proxy_mode_mismatch(running_config) + elif not helpers._check_proxy(port): click.echo(f" Warning: No proxy detected on port {port}") - elif vertex_api_url or clear_vertex_api_url: + elif ( + vertex_api_url + or clear_vertex_api_url + or os.environ.get("HEADROOM_MODE") + or os.environ.get("HEADROOM_MIN_TOKENS") is not None + or os.environ.get("HEADROOM_EXCLUDE_TOOLS") is not None + ): health_payload = helpers._query_proxy_health(port) running_config = helpers._proxy_health_config(health_payload) if running_config is None: running_config = helpers._query_proxy_config(port) - running_vertex_url = ( - _normalize_proxy_api_url(running_config.get("vertex_api_url")) - if running_config is not None - else None - ) - requested_vertex_url = _normalize_proxy_api_url(vertex_api_url) - if running_vertex_url != requested_vertex_url: - click.echo( - " Warning: --no-proxy is set, but the running proxy does not " - "advertise the requested Vertex target. Requests may still go " - "to the proxy's existing Vertex upstream." + if vertex_api_url or clear_vertex_api_url: + running_vertex_url = ( + _normalize_proxy_api_url(running_config.get("vertex_api_url")) + if running_config is not None + else None ) + requested_vertex_url = _normalize_proxy_api_url(vertex_api_url) + if running_vertex_url != requested_vertex_url: + click.echo( + " Warning: --no-proxy is set, but the running proxy does not " + "advertise the requested Vertex target. Requests may still go " + "to the proxy's existing Vertex upstream." + ) + _warn_proxy_mode_mismatch(running_config) return None, port @@ -4825,6 +5259,11 @@ def _proxy_start_lock(port: int) -> Any: yield +def _proxy_status_line(status: str, port: int) -> str: + """One banner line for the proxy, naming its host:port once (#3426).""" + return f" {status} — dashboard: http://127.0.0.1:{port}/dashboard" + + @wraps(_ensure_proxy_unlocked) def _ensure_proxy( port: int, @@ -4841,11 +5280,19 @@ def _ensure_proxy( def _client_marker_path(port: int) -> Path: - """Path to this process's wrap-client marker for ``port``.""" + """Path to this process's wrap-client marker for ``port``. + + Mode 0700: markers carry ``cwd``/``session_token`` the proxy treats as + an authoritative binding, so only the invoking OS user may read them. + """ from headroom import paths as _paths d = _paths.proxy_clients_dir(port) d.mkdir(parents=True, exist_ok=True) + try: + os.chmod(d, 0o700) + except OSError: + pass return d / f"{os.getpid()}.json" @@ -4859,14 +5306,25 @@ def _proc_identity(pid: int) -> tuple[str, float] | None: return proc_identity(pid) -def _register_proxy_client(port: int) -> None: +def _register_proxy_client(port: int, *, session_token: str | None = None) -> None: """Register this wrap process as a live client of the shared proxy. - Best-effort: a failed write just means our marker is missing, and the - liveness pruning in :func:`_live_proxy_clients` is the real safety net. + Also records ``cwd`` (wrap is the coding-agent CLI's real blocking + parent, so this is authoritative) and, if given, ``session_token`` -- + also injected into the wrapped CLI's headers so the proxy can resolve + which registered cwd a request belongs to. + + Best-effort: a failed write just means our marker is missing, and + :func:`_live_proxy_clients`'s liveness pruning is the real safety net. """ try: - payload: dict[str, Any] = {"pid": os.getpid(), "started_at": time.time()} + payload: dict[str, Any] = { + "pid": os.getpid(), + "started_at": time.time(), + "cwd": os.getcwd(), + } + if session_token: + payload["session_token"] = session_token ident = _proc_identity(os.getpid()) if ident is not None: payload["start_src"], payload["start_time"] = ident @@ -5127,11 +5585,6 @@ def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path: return config_path.parent / "extensions" -def _normalize_openclaw_gateway_provider_ids(provider_ids: tuple[str, ...] | None) -> list[str]: - """Normalize configured OpenClaw provider ids, defaulting to openai-codex.""" - return _normalize_openclaw_gateway_provider_ids_impl(provider_ids) - - def _read_openclaw_config_value(openclaw_bin: str, path: str) -> Any | None: """Read an OpenClaw config value when present, returning None on missing paths.""" result = run( @@ -5291,6 +5744,7 @@ def wrap(ctx: click.Context) -> None: headroom wrap vibe # Mistral Vibe headroom wrap grok # Grok CLI (xAI) headroom wrap cursor # Cursor (prints config instructions) + headroom wrap antigravity # Antigravity IDE (prints config instructions) headroom wrap grok-build # Grok Build (updates ~/.grok/config.toml) headroom wrap cline # Cline (VS Code; prints config instructions) headroom wrap continue # Continue (VS Code/JetBrains; injects systemMessage) @@ -5382,6 +5836,42 @@ def wrap_selfheal(marker: str | None) -> None: # Claude Code # ============================================================================= +# Hostnames that mean "this machine" — used to avoid feeding the proxy its own +# URL back as the upstream when the user already had ANTHROPIC_BASE_URL pointed +# at a previous Headroom instance. +_LOCAL_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def _detect_inbound_anthropic_upstream(port: int) -> str | None: + """Return a pre-set ANTHROPIC_BASE_URL that is NOT this proxy, else None. + + Issue #1353: users who already route Claude Code at a LiteLLM (or any + custom Anthropic-compatible) gateway via ``ANTHROPIC_BASE_URL`` lose that + routing when they run ``headroom wrap claude`` — wrap overwrites + ANTHROPIC_BASE_URL with the local proxy URL and silently forwards to + api.anthropic.com instead of the LiteLLM URL the user configured. Treat + the pre-existing value as the proxy's upstream so compression layers on + top of the user's gateway instead of replacing it. URLs pointing at this + proxy instance are ignored to avoid a self-referential forwarding loop. + """ + + base_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip() + if not base_url: + return None + try: + parsed = urllib.parse.urlsplit(base_url) + except ValueError: + return None + hostname = (parsed.hostname or "").lower() + if hostname in _LOCAL_HOSTNAMES: + try: + parsed_port = parsed.port + except ValueError: + return None + if parsed_port == port: + return None + return base_url + @wrap.command(context_settings={"ignore_unknown_options": True}) @_retired_context_tool_option @@ -5394,30 +5884,24 @@ def wrap_selfheal(marker: str | None) -> None: help="Skip headroom MCP server registration (compression markers will be unactionable)", ) @_code_memory_option -@click.option( - "--no-tokensave", - is_flag=True, - hidden=True, - help="Deprecated and ignored: tokensave was retired; Serena is the default code memory.", -) -@click.option( - "--serena", - is_flag=True, - hidden=True, - help="Deprecated: use --code-memory serena. Force the Serena MCP compressor on.", -) -@click.option( - "--no-serena", - is_flag=True, - hidden=True, - help="Deprecated: use --code-memory none. Register no code-memory MCP.", -) +@_code_memory_scope_option +@_legacy_code_memory_options @click.option( "--code-graph", is_flag=True, help="Enable the proxy's live code-graph file watcher for the current project.", ) @click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)") +@click.option( + "--project-settings", + "--project-settings-injection", + "project_settings", + is_flag=True, + help=( + "Write .claude/settings.local.json so daemon-spawned Claude workers inherit " + "Headroom routing. Env: HEADROOM_CLAUDE_PROJECT_SETTINGS=1." + ), +) @click.option( "--learn", is_flag=True, help="Enable live traffic learning (patterns saved to MEMORY.md)" ) @@ -5469,6 +5953,7 @@ def claude( no_serena: bool, code_graph: bool, no_proxy: bool, + project_settings: bool, learn: bool, memory: bool, tool_search: str | None, @@ -5492,17 +5977,17 @@ def claude( headroom wrap claude --resume # Resume a session headroom wrap claude -- -p # Claude in print mode headroom wrap claude --no-mcp # Skip MCP retrieve tool registration + headroom wrap claude --project-settings # Persist proxy routing in .claude/settings.local.json headroom wrap claude --code-memory none # No code-memory MCP + headroom wrap claude --code-memory-scope user # Serena in every session, not just here headroom wrap claude --1m # Preserve the 1M context window """ if prepare_only: return - claude_bin = shutil.which("claude") - if not claude_bin: - click.echo("Error: 'claude' not found in PATH.") - click.echo("Install Claude Code: https://docs.anthropic.com/en/docs/claude-code") - raise SystemExit(1) + claude_bin = _require_binary( + "claude", install_hint="Install Claude Code: https://docs.anthropic.com/en/docs/claude-code" + ) # Validate --tool-search up front so a typo fails before we start the proxy. if tool_search is not None: @@ -5513,6 +5998,7 @@ def claude( _tool_search_not_written = object() _saved_tool_search: list[object | str | None] = [_tool_search_not_written] _settings_foundry: list[bool] = [False] + _wrote_project_settings: list[bool] = [False] port_holder: list[int] = [port] _settings_vertex: list[bool] = [False] # Bind before the try so the finally can always reference it. It is otherwise @@ -5606,7 +6092,24 @@ def claude( proxy_url = _claude_proxy_base_url(port) vertex_upstream = _vertex_target_api_url_from_claude_env(proxy_url) if use_vertex else None - _register_proxy_client(port) + # Issue #1353: when none of the explicit override channels (Foundry, + # Vertex, ANTHROPIC_TARGET_API_URL) apply, inherit a pre-existing + # ANTHROPIC_BASE_URL as the upstream so wrap doesn't silently revert + # the user's LiteLLM/custom-gateway routing back to api.anthropic.com. + custom_upstream: str | None = None + if ( + not foundry_upstream + and not use_vertex + and not os.environ.get("ANTHROPIC_TARGET_API_URL") + ): + custom_upstream = _detect_inbound_anthropic_upstream(port) + + upstream_for_proxy = foundry_upstream or custom_upstream + + # One token for the whole session -- written into the marker below + # and, further down, into the header the wrapped CLI sends. + _session_token = secrets.token_urlsafe(32) + _register_proxy_client(port, session_token=_session_token) proxy_holder[0], actual_port = _ensure_proxy( port, no_proxy, @@ -5616,13 +6119,13 @@ def claude( code_graph=code_graph, backend=backend, region=region, - anthropic_api_url=foundry_upstream, + anthropic_api_url=upstream_for_proxy, vertex_api_url=vertex_upstream, clear_vertex_api_url=use_vertex and vertex_upstream is None, ) if actual_port != port: _unregister_proxy_client(port) - _register_proxy_client(actual_port) + _register_proxy_client(actual_port, session_token=_session_token) port_holder[0] = actual_port _push_runtime_env(actual_port, no_proxy) @@ -5634,10 +6137,15 @@ def claude( click.echo(" Skipping MCP retrieve tool (--no-mcp)") # Coding-task compressor: Serena (retires any legacy tokensave entry). + # Registered per project by default so a Serena that cannot start takes + # down this project's sessions only, never every Claude Code session on + # the machine (#2787). --code-memory-scope user restores the old + # machine-wide registration. The Headroom MCP registrar above keeps user + # scope: it is our own server and every wrap exercises it. from headroom.mcp_registry import CLAUDE_SERENA_CONTEXT, ClaudeRegistrar _setup_coding_compressor( - ClaudeRegistrar(), + ClaudeRegistrar(scope=_claude_code_memory_scope()), serena_context=CLAUDE_SERENA_CONTEXT, serena=serena, no_serena=no_serena, @@ -5657,6 +6165,8 @@ def claude( click.echo( f" Foundry mode: ANTHROPIC_FOUNDRY_BASE_URL={_foundry_proxy_url(proxy_url)} → upstream {foundry_upstream}" ) + elif custom_upstream: + click.echo(f" ANTHROPIC_BASE_URL={proxy_url} → upstream {custom_upstream}") else: click.echo(f" ANTHROPIC_BASE_URL={proxy_url}") # Issue #1779: Claude Code 2.1.196+ deterministically disables @@ -5672,7 +6182,12 @@ def claude( if remote_control_applies_to_auth(os.environ) else None ) - if remote_control_gate_active(proxy_url, os.environ, _cc_version): + _rc_gated = remote_control_gate_active(proxy_url, os.environ, _cc_version) + if _rc_gated and not verbose: + # Issue #3426: one actionable line by default; -v restores the + # full explanation (versions, sibling gates) below. + click.echo(" " + remote_control_gate_short_message(version=_cc_version)) + elif _rc_gated: click.echo( " " + remote_control_gate_message( @@ -5711,64 +6226,79 @@ def claude( else: env["ANTHROPIC_BASE_URL"] = proxy_url - # Issue #951: write to settings.json so daemon-spawned conversation - # workers (which read settings.json fresh rather than inheriting the - # daemon's environment) also route through Headroom. + # Recover an older crashed wrap even when new project settings writes + # are disabled. The marker records the prior value to restore; without + # one, default wraps leave project settings untouched (#1599). _settings_vertex[0] = bool(use_vertex) _settings_foundry[0] = bool(foundry_upstream) and not _settings_vertex[0] - # _wrap_settings_path is bound before the try (above) so the finally is - # always safe; the value is unchanged here. _check_and_clear_stale_wrap_marker( _wrap_settings_path, key=_claude_wrap_base_url_env_key( foundry_mode=_settings_foundry[0], vertex_mode=_settings_vertex[0] ), ) - _saved_base_url[0] = _write_claude_wrap_base_url( - ( - _foundry_proxy_url(proxy_url) - if _settings_foundry[0] - else env["ANTHROPIC_VERTEX_BASE_URL"] - if _settings_vertex[0] - else proxy_url - ), - foundry_mode=_settings_foundry[0], - vertex_mode=_settings_vertex[0], - settings_path=_wrap_settings_path, - # The URL above is built from actual_port; stamping the requested - # port here made the marker/owner claim point at the wrong port - # whenever _ensure_proxy fell back to another one. - port=actual_port, - ) - # Issue #2221: pair the marker just written with a reader. wrap installs - # no hook of its own, so a session that only ran `wrap` (never `init`) - # had nothing to clear a dead-proxy base_url. SessionStart-only. - _ensure_claude_wrap_selfheal_hook(_wrap_settings_path) + if _claude_project_settings_enabled(project_settings): + _saved_base_url[0] = _write_claude_wrap_base_url( + ( + _foundry_proxy_url(proxy_url) + if _settings_foundry[0] + else env["ANTHROPIC_VERTEX_BASE_URL"] + if _settings_vertex[0] + else proxy_url + ), + foundry_mode=_settings_foundry[0], + vertex_mode=_settings_vertex[0], + settings_path=_wrap_settings_path, + port=actual_port, + ) + # Issue #2221: pair the marker just written with a reader. wrap installs + # no hook of its own, so a session that only ran `wrap` (never `init`) + # had nothing to clear a dead-proxy base_url. SessionStart-only. + _wrote_project_settings[0] = True + _ensure_claude_wrap_selfheal_hook(_wrap_settings_path) + elif verbose: + skip_reason = _claude_project_settings_skip_reason() + click.echo( + " Skipping project-local Claude settings " + f"({skip_reason}); daemon-spawned workers may not inherit Headroom." + ) # Per-project savings attribution: tag every request with the launch # directory's name via X-Headroom-Project (user override wins). _apply_project_header_env(env) + # Same token written into the marker above. + _apply_session_token_header_env(env, _session_token) # 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 _wrote_project_settings[0]: + _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 # rather than repeat "kept on" (issue #1779 accuracy rule). - _tool_search_state = ( - "on-demand tool loading kept on" - if _tool_search_mode_is_active(_tool_search_value) - else "on-demand tool loading DISABLED per your setting" - ) - click.echo( - f" {_TOOL_SEARCH_ENV}={_tool_search_value} ({_tool_search_state}; issue #746)" - ) + _tool_search_active = _tool_search_mode_is_active(_tool_search_value) + if verbose: + _tool_search_state = ( + "on-demand tool loading kept on" + if _tool_search_active + else "on-demand tool loading DISABLED per your setting" + ) + click.echo( + f" {_TOOL_SEARCH_ENV}={_tool_search_value} ({_tool_search_state}; issue #746)" + ) + else: + # Issue #3426: state the outcome once; -v shows the env var and issue. + click.echo( + " On-demand tool loading: kept on (this session)" + if _tool_search_active + else " On-demand tool loading: off (this session)" + ) elif verbose: click.echo( f" {_TOOL_SEARCH_ENV}={env.get(_TOOL_SEARCH_ENV)} " @@ -5807,12 +6337,13 @@ def claude( 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], - vertex_mode=_settings_vertex[0], - settings_path=_wrap_settings_path, - ) + if _wrote_project_settings[0]: + _restore_claude_wrap_base_url( + _saved_base_url[0], + foundry_mode=_settings_foundry[0], + vertex_mode=_settings_vertex[0], + settings_path=_wrap_settings_path, + ) cleanup() @@ -6050,14 +6581,13 @@ def copilot( explicitly with GITHUB_COPILOT_API_URL (the override flows through to upstream). See TESTING-copilot-subscription.md for details. """ - copilot_bin = shutil.which("copilot") - if not copilot_bin: - click.echo("Error: 'copilot' not found in PATH.") - click.echo( + copilot_bin = _require_binary( + "copilot", + install_hint=( "Install GitHub Copilot CLI: " "https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli" - ) - raise SystemExit(1) + ), + ) explicit_subscription = subscription effective_backend = backend or os.environ.get("HEADROOM_BACKEND") @@ -6339,6 +6869,12 @@ def _print_setup(actual_port: int) -> None: vscode_proxy_url(actual_port, _project_name_from_cwd()), ) click.echo(f" VS Code Copilot proxy settings {action}: {target_settings}") + for name, profile_settings in unrouted_vscode_profiles(target_settings.parent): + click.echo( + f" Warning: VS Code profile '{name}' keeps its own settings, so Copilot " + "there still bypasses Headroom. Route it with: headroom wrap vscode " + f'--settings-file "{profile_settings}"' + ) click.echo( " Keep using Copilot's normal model picker; the selected model is preserved." ) @@ -6653,11 +7189,9 @@ def _run_codex_wrap( ) return - codex_bin = shutil.which("codex") - if not codex_bin: - click.echo("Error: 'codex' not found in PATH.") - click.echo("Install Codex CLI: npm install -g @openai/codex") - raise SystemExit(1) + codex_bin = _require_binary( + "codex", install_hint="Install Codex CLI: npm install -g @openai/codex" + ) active_codex_home = _codex_home_dir() _offer_dangling_codex_recovery(active_codex_home) @@ -6717,24 +7251,7 @@ def configure_codex_launch( help="Skip headroom MCP server registration (compression markers will be unactionable)", ) @_code_memory_option -@click.option( - "--no-tokensave", - is_flag=True, - hidden=True, - help="Deprecated and ignored: tokensave was retired; Serena is the default code memory.", -) -@click.option( - "--serena", - is_flag=True, - hidden=True, - help="Deprecated: use --code-memory serena. Force the Serena MCP compressor on.", -) -@click.option( - "--no-serena", - is_flag=True, - hidden=True, - help="Deprecated: use --code-memory none. Register no code-memory MCP.", -) +@_legacy_code_memory_options @click.option( "--code-graph", is_flag=True, @@ -6865,11 +7382,7 @@ def aider( if prepare_only: return - aider_bin = shutil.which("aider") - if not aider_bin: - click.echo("Error: 'aider' not found in PATH.") - click.echo("Install aider: pip install aider-chat") - raise SystemExit(1) + aider_bin = _require_binary("aider", install_hint="Install aider: pip install aider-chat") env, env_vars_display = _build_aider_launch_env( port, os.environ, project=_project_name_from_cwd() @@ -6936,11 +7449,9 @@ def vibe( if prepare_only: return - vibe_bin = shutil.which("vibe") - if not vibe_bin: - click.echo("Error: 'vibe' not found in PATH.") - click.echo("Install Mistral Vibe: https://github.com/mistralai/mistral-vibe") - raise SystemExit(1) + vibe_bin = _require_binary( + "vibe", install_hint="Install Mistral Vibe: https://github.com/mistralai/mistral-vibe" + ) env, env_vars_display = _build_mistral_vibe_launch_env( port, os.environ, project=_project_name_from_cwd() @@ -7015,11 +7526,9 @@ def kimi( if prepare_only: return - kimi_bin = shutil.which("kimi") or shutil.which("kimi-cli") - if not kimi_bin: - click.echo("Error: 'kimi' (or 'kimi-cli') not found in PATH.") - click.echo("Install Kimi CLI: https://github.com/MoonshotAI/kimi-cli") - raise SystemExit(1) + kimi_bin = _require_binary( + "kimi", "kimi-cli", install_hint="Install Kimi CLI: https://github.com/MoonshotAI/kimi-cli" + ) project = _project_name_from_cwd() env, env_vars_display = _build_kimi_launch_env(port, os.environ, project=project) @@ -7064,24 +7573,7 @@ def configure_kimi_launch( @proxy_port_option() @click.option("--no-mcp", is_flag=True, help="Skip headroom MCP server registration") @_code_memory_option -@click.option( - "--no-tokensave", - is_flag=True, - hidden=True, - help="Deprecated and ignored: tokensave was retired; Serena is the default code memory.", -) -@click.option( - "--serena", - is_flag=True, - hidden=True, - help="Deprecated: use --code-memory serena. Force the Serena MCP compressor on.", -) -@click.option( - "--no-serena", - is_flag=True, - hidden=True, - help="Deprecated: use --code-memory none. Register no code-memory MCP.", -) +@_legacy_code_memory_options @click.option( "--code-graph", is_flag=True, @@ -7154,11 +7646,9 @@ def grok( if prepare_only: return - grok_bin = shutil.which("grok") - if not grok_bin: - click.echo("Error: 'grok' not found in PATH.") - click.echo("Install Grok CLI: https://docs.x.ai/docs/grok-cli") - raise SystemExit(1) + grok_bin = _require_binary( + "grok", install_hint="Install Grok CLI: https://docs.x.ai/docs/grok-cli" + ) env, env_vars_display = _build_grok_launch_env( port, os.environ, project=_project_name_from_cwd() @@ -7240,6 +7730,62 @@ def _print_cursor_setup(actual_port: int) -> None: ) +# ============================================================================= +# Antigravity IDE +# ============================================================================= + + +@wrap.command(context_settings={"ignore_unknown_options": True}) +@_retired_context_tool_option +@proxy_port_option() +@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)") +@click.option("--learn", is_flag=True, help="Enable live traffic learning") +@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--prepare-only", is_flag=True, hidden=True) +def antigravity( + port: int, + no_proxy: bool, + learn: bool, + memory: bool, + verbose: bool, + prepare_only: bool, +) -> None: + """Start Headroom proxy for use with Antigravity IDE. + + \b + Antigravity reads its model endpoints from its model-provider settings, + not from environment variables. This command starts the proxy and prints + the settings to add as a custom OpenAI-compatible model provider. + + \b + After running this command, open Antigravity and add a custom model + provider with the printed base URL. Antigravity fetches the model list + from GET /v1/models automatically. + + \b + Example: + headroom wrap antigravity # Start proxy + Antigravity settings + headroom wrap antigravity --port 9999 # Custom proxy port + """ + if prepare_only: + return + + def _print_antigravity_setup(actual_port: int) -> None: + for line in _render_antigravity_setup_lines(actual_port, project=_project_name_from_cwd()): + click.echo(line) + + _run_proxy_only_watcher( + agent_label="antigravity", + port=port, + no_proxy=no_proxy, + learn=learn, + memory=memory, + agent_type="antigravity", + print_setup_lines=_print_antigravity_setup, + ) + + # ============================================================================= # Grok Build # ============================================================================= @@ -7280,7 +7826,7 @@ def grok_build( config_file = inject_grok_provider_config(port, project=project) click.echo(f" Grok config: injected Headroom proxy override into {config_file}") except Exception as e: - click.echo(f" Warning: could not update Grok config: {e}") + click.echo(f" Warning: could not update Grok config {grok_config_paths()[0]}: {e}") return def _print_grok_build_setup(actual_port: int) -> None: @@ -7289,7 +7835,7 @@ def _print_grok_build_setup(actual_port: int) -> None: click.echo(f" Grok config: injected Headroom proxy override into {config_file}") click.echo() except Exception as e: - click.echo(f" Warning: could not update Grok config: {e}") + click.echo(f" Warning: could not update Grok config {grok_config_paths()[0]}: {e}") click.echo() for line in _render_grok_build_setup_lines(actual_port, project=project): click.echo(line) @@ -7512,6 +8058,7 @@ def _print_continue_setup(actual_port: int) -> None: # ============================================================================= + # OpenClaw # ============================================================================= @@ -7796,6 +8343,16 @@ def openclaw( is_flag=True, help="Route headroom/* models through the authenticated GitHub Copilot subscription", ) +@click.option( + "--openai-api-url", + default=None, + envvar="OPENAI_TARGET_API_URL", + help=( + "Upstream base URL for OpenAI-compatible traffic, e.g. " + "https://api.deepseek.com/v1. Without it the proxy forwards to " + "https://api.openai.com/v1 (env: OPENAI_TARGET_API_URL)." + ), +) @click.option("--learn", is_flag=True, help="Enable live traffic learning") @click.option("--memory", is_flag=True, help="Enable persistent cross-session memory") @click.option( @@ -7813,6 +8370,7 @@ def opencode( code_graph: bool, no_proxy: bool, copilot_subscription: bool, + openai_api_url: str | None, learn: bool, memory: bool, backend: str | None, @@ -7828,6 +8386,8 @@ def opencode( Sets OPENCODE_CONFIG_CONTENT to route all OpenCode API calls through Headroom. Configures a headroom provider via @ai-sdk/openai-compatible. Also sets OPENAI_BASE_URL and ANTHROPIC_BASE_URL as fallbacks. + On OpenCode 2.x, adds --standalone (unless --server is given) so a private + server loads that config instead of a running background service. \b Examples: @@ -7838,9 +8398,25 @@ def opencode( headroom wrap opencode --port 9999 # Custom proxy port headroom wrap opencode --backend anyllm --anyllm-provider groq headroom wrap opencode --copilot-subscription # Use a GitHub Copilot subscription + headroom wrap opencode --openai-api-url https://api.deepseek.com/v1 + + \b + Without --openai-api-url the proxy forwards OpenAI-compatible traffic to + https://api.openai.com/v1, so a third-party key (DeepSeek, Together, + OpenRouter, ...) is rejected upstream with OpenAI's 401 "Incorrect API key + provided". Point the proxy at the real upstream instead: + + \b + headroom wrap opencode --openai-api-url https://api.deepseek.com/v1 + OPENAI_TARGET_API_URL=https://api.deepseek.com/v1 headroom wrap opencode """ subscription_resolution = None if copilot_subscription: + if openai_api_url: + raise click.ClickException( + "--openai-api-url cannot be combined with --copilot-subscription; " + "the subscription resolves its own upstream." + ) effective_backend = backend or os.environ.get("HEADROOM_BACKEND") if effective_backend not in (None, "", "anthropic"): raise click.ClickException( @@ -7867,11 +8443,15 @@ def opencode( # config without launching, so it is exempt. opencode_bin: str | None = None if not prepare_only: - opencode_bin = shutil.which("opencode") - if not opencode_bin: - click.echo("Error: 'opencode' not found in PATH.") - click.echo("Install OpenCode: https://opencode.ai") - raise SystemExit(1) + opencode_bin = _require_binary( + "opencode", install_hint="Install OpenCode: https://opencode.ai" + ) + + # Likewise refuse a reused proxy that cannot honor --openai-api-url before + # touching OpenCode's config or registering a client marker; the same check + # inside _ensure_proxy would only fire after those edits. + if not prepare_only and no_proxy and openai_api_url: + _require_no_proxy_openai_upstream(port, openai_api_url) # Snapshot OpenCode config.json BEFORE any wrap-time mutation so # `headroom unwrap opencode` can restore the user's pre-wrap state. @@ -7910,7 +8490,7 @@ def opencode( _inject_memory_agents_md(agents_md) if prepare_only: - inject_opencode_provider_config(port) + inject_opencode_provider_config(port, keep_user_entries=bool(openai_api_url)) return # Past the prepare-only return the launch path always ran the binary check @@ -7920,7 +8500,11 @@ def opencode( # Register our proxy client marker BEFORE _ensure_proxy so that another # wrapper's cleanup sees us as an active client and doesn't terminate a # shared proxy during the startup gap. - _register_proxy_client(port) + # + # Mirrors claude()'s token minting; only reaches OpenCode when the + # plugin layer is loaded (see build_launch_env). + _session_token = secrets.token_urlsafe(32) + _register_proxy_client(port, session_token=_session_token) # Resolve port before config injection so the provider block and MCP # URL both point at the port the proxy will actually be on. @@ -7934,7 +8518,10 @@ def opencode( backend=backend, anyllm_provider=anyllm_provider, region=region, - openai_api_url=(subscription_resolution.api_url if subscription_resolution else None), + openai_api_url=( + subscription_resolution.api_url if subscription_resolution else openai_api_url + ), + require_openai_api_url=bool(openai_api_url), copilot_api_token=(subscription_resolution.token if subscription_resolution else None), copilot_refresh_oauth_token=( subscription_resolution.refresh_oauth_token if subscription_resolution else None @@ -7950,7 +8537,7 @@ def opencode( # cleanup tracking stays accurate and update MCP config. if actual_port != port: _unregister_proxy_client(port) - _register_proxy_client(actual_port) + _register_proxy_client(actual_port, session_token=_session_token) if not no_mcp: from headroom.mcp_registry import OpencodeRegistrar @@ -7960,24 +8547,34 @@ def opencode( if subscription_resolution is not None: _scrub_copilot_subscription_launch_env(launch_environ) env, env_vars_display = _build_opencode_launch_env( - actual_port, launch_environ, project=_project_name_from_cwd(), include_mcp=not no_mcp + actual_port, + launch_environ, + project=_project_name_from_cwd(), + include_mcp=not no_mcp, + session_token=_session_token, ) # Inject Headroom provider into OpenCode config so traffic routes through proxy. - inject_opencode_provider_config(actual_port) + inject_opencode_provider_config(actual_port, keep_user_entries=bool(openai_api_url)) if memory: mem_dir = Path.cwd() / ".headroom" _inject_memory_mcp_config( os.environ.get("USER", os.environ.get("USERNAME", "default")), ) + # OpenCode 2.x otherwise attaches to an already-running background + # service that never sees this launch's OPENCODE_CONFIG_CONTENT. + launch_args = with_opencode_standalone(opencode_args, opencode_major_version(opencode_bin)) + if verbose and launch_args != tuple(opencode_args): + click.echo(" OpenCode 2.x: adding --standalone so it loads Headroom's config") + # Proxy already started by _ensure_proxy above; tell _launch_tool to # skip duplicate startup. launch_started = True try: _launch_tool( binary=opencode_bin, - args=opencode_args, + args=launch_args, env=env, port=actual_port, no_proxy=True, @@ -8011,14 +8608,6 @@ def opencode( _opencode_proxy.kill() -def _opencode_home_dir() -> Path: - """Return the OpenCode home/config directory.""" - env_path = os.environ.get("OPENCODE_HOME", "").strip() - if env_path: - return Path(env_path).expanduser() - return Path.home() / ".config" / "opencode" - - # ============================================================================= # OpenCode (unwrap) # ============================================================================= @@ -8051,6 +8640,7 @@ def unwrap_opencode(port: int, no_stop_proxy: bool) -> None: click.echo() config_file, backup_file = opencode_config_paths() + migrate_legacy_opencode_jsonc_backup(config_file, backup_file) if backup_file.exists(): try: @@ -8230,7 +8820,7 @@ def unwrap_grok_build(port: int, no_stop_proxy: bool) -> None: ) @click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy") def unwrap_codex(port: int, no_stop_proxy: bool) -> None: - """Undo ``headroom wrap codex`` edits to the active Codex config file. + """Undo ``headroom wrap codex`` and ``headroom init codex`` routing in the Codex config. Behaviour: @@ -8359,11 +8949,9 @@ def omp( _inject_omp_models_override(port, _project_name_from_cwd()) return - omp_bin = shutil.which("omp") - if not omp_bin: - click.echo("Error: 'omp' not found in PATH.") - click.echo("Install Oh My Pi: npm install -g @oh-my-pi/pi-coding-agent") - raise SystemExit(1) + omp_bin = _require_binary( + "omp", install_hint="Install Oh My Pi: npm install -g @oh-my-pi/pi-coding-agent" + ) env, env_vars_display = _build_omp_launch_env( port, os.environ, project=_project_name_from_cwd() @@ -8505,6 +9093,65 @@ def unwrap_zcode(port: int, no_stop_proxy: bool) -> None: click.echo() +def _warn_proxy_mode_mismatch(running_config: dict[str, Any] | None) -> None: + """Warn when reuse ignores this session's startup-only settings. + + Do not restart a shared proxy: other clients may be attached to it. + """ + config = running_config or {} + mismatches: list[str] = [] + requested_min = os.environ.get("HEADROOM_MIN_TOKENS") + running_min = config.get("min_tokens_to_crush") + if requested_min is not None and isinstance(running_min, int): + try: + requested_min_value = int(requested_min) + except ValueError: + requested_min_value = None + if requested_min_value is not None and requested_min_value != running_min: + mismatches.append(f"HEADROOM_MIN_TOKENS={requested_min_value} (running: {running_min})") + + requested_excludes = os.environ.get("HEADROOM_EXCLUDE_TOOLS") + running_excludes = config.get("exclude_tools") + if ( + requested_excludes is not None + and isinstance(running_excludes, list) + and all(isinstance(name, str) for name in running_excludes) + ): + from headroom.config import DEFAULT_EXCLUDE_TOOLS + + defaults = {name.lower() for name in DEFAULT_EXCLUDE_TOOLS} + requested_names = { + name.strip().lower() for name in requested_excludes.split(",") if name.strip() + } + running_names = {name.lower() for name in running_excludes} + if requested_names | defaults != running_names | defaults: + mismatches.append( + f"HEADROOM_EXCLUDE_TOOLS={sorted(requested_names)!r} " + f"(running: {sorted(running_names)!r})" + ) + + if mismatches: + click.echo( + " Warning: this session requested " + + "; ".join(mismatches) + + ", but those settings are fixed at proxy startup. " + "Restart the proxy, or use --port for a separate one." + ) + requested = os.environ.get("HEADROOM_MODE") + running = (running_config or {}).get("mode") + if not requested or not isinstance(running, str): + return + from headroom.proxy.proxy_mode_policy import normalize_proxy_mode_decision + + decision = normalize_proxy_mode_decision(requested, default=running) + if not decision.unknown and decision.normalized != running: + click.echo( + f" Warning: this session requested {decision.normalized!r} mode but the " + f"running proxy is in {running!r} mode (mode is fixed at proxy startup). " + "Restart the proxy, or use --port for a separate one." + ) + + # ============================================================================= # Registry-generated wrap commands # ============================================================================= @@ -8529,11 +9176,12 @@ def _run( if prepare_only: return - tool_bin = shutil.which(target.binary) - if not tool_bin: - click.echo(f"Error: '{target.binary}' not found in PATH.") - click.echo(target.install_hint) - raise SystemExit(1) + tool_bin = _require_binary(target.binary, install_hint=target.install_hint) + + # Exported before proxy startup so _start_proxy forwards it as --mode; + # an explicit HEADROOM_MODE always wins. + if target.default_mode and not os.environ.get("HEADROOM_MODE"): + os.environ["HEADROOM_MODE"] = target.default_mode env, env_vars_display = _build_registry_launch_env( target, @@ -8542,6 +9190,21 @@ def _run( project=_project_name_from_cwd() if target.project_prefix else None, ) + configure_launch = None + if target.preflight is not None: + preflight = target.preflight + + def configure_launch( + actual_port: int, args: tuple, env: dict[str, str], display: list[str] + ) -> tuple[tuple, dict[str, str], list[str]]: + # Runs on the final env: _ensure_proxy may have moved to another + # port, and the saved-gateway comparison must use that URL. + # Raising here still tears the proxy down via _launch_tool's + # cleanup. + if problem := preflight(env): + raise click.ClickException(problem) + return args, env, display + _launch_tool( binary=tool_bin, args=tool_args, @@ -8557,6 +9220,8 @@ def _run( backend=backend, anyllm_provider=anyllm_provider, region=region, + openai_api_url=target.openai_api_url, + configure_launch=configure_launch, ) _run.__doc__ = target.help_text diff --git a/headroom/client.py b/headroom/client.py index c711333991..8ae403f10a 100644 --- a/headroom/client.py +++ b/headroom/client.py @@ -642,46 +642,6 @@ def _create( self._storage.save(metrics) raise - def _call_openai( - self, - *, - model: str, - messages: list[dict[str, Any]], - stream: bool, - metrics: RequestMetrics, - **kwargs: Any, - ) -> Any: - """Call OpenAI-style API.""" - return call_client_transport( - "openai", - self, - model=model, - messages=messages, - stream=stream, - metrics=metrics, - **kwargs, - ) - - def _call_anthropic( - self, - *, - model: str, - messages: list[dict[str, Any]], - stream: bool, - metrics: RequestMetrics, - **kwargs: Any, - ) -> Any: - """Call Anthropic-style API.""" - return call_client_transport( - "anthropic", - self, - model=model, - messages=messages, - stream=stream, - metrics=metrics, - **kwargs, - ) - def _wrap_stream( self, stream: Iterator[Any], diff --git a/headroom/compress.py b/headroom/compress.py index c40e0993c2..4fb5f59ac7 100644 --- a/headroom/compress.py +++ b/headroom/compress.py @@ -57,20 +57,25 @@ from __future__ import annotations import logging +import os import threading from dataclasses import dataclass, field, replace -from typing import Any +from typing import TYPE_CHECKING, Any from .agent_savings import apply_agent_savings_profile from .observability import get_otel_metrics from .pipeline import PipelineExtensionManager, PipelineStage, summarize_routing_markers from .utils import extract_user_query as _extract_user_query +if TYPE_CHECKING: + from .config import MessageDecision + logger = logging.getLogger(__name__) -# Lazy-initialized singleton pipeline +# Lazy-initialized singleton pipelines (default + agent regime) _pipeline = None +_agent_pipeline = None _pipeline_lock = threading.Lock() @@ -146,6 +151,34 @@ class CompressConfig: savings_profile: str | None = None """Named high-savings profile, e.g. 'agent-90' for Codex/Claude/Cursor.""" + diagnostics: bool = False + """Collect per-message compression decisions. Also enabled by the + ``HEADROOM_DIAGNOSTICS=1`` environment variable. When True, the returned + :class:`CompressResult` carries a ``diagnostics`` list of + :class:`~headroom.config.MessageDecision` objects — one per message — + describing which action was taken (compressed, protected, skipped …) and + how many tokens were spent before/after.""" + + # Regime + mode: str | None = None + """Compression regime. + + - ``None`` (default): the original behavior — content-aware compression that + may use CCR removal (replace-with-marker + retrieve later). Best for a + *proxy* compressing conversation history the model has moved past. + - ``"agent"``: live-agent mode for compressing tool results an agent will + *read back*. Lossless densification only — no CCR/removal, no ML text + compression — so it is deterministic and prompt-cache-safe (the same tool + output densifies to identical bytes every turn, keeping the cached prefix + stable). Forces :attr:`verify_lossless`, so output is never lossy: any + message that cannot be proven to round-trip is reverted to its original. + Use :func:`densify` as the shorthand.""" + + verify_lossless: bool = False + """Round-trip each compressed message and revert any that is not provably + lossless (keeps the original for that message). Forced on when + ``mode='agent'``. Sets :attr:`CompressResult.lossless`.""" + @dataclass class CompressResult: @@ -166,6 +199,15 @@ class CompressResult: tokens_saved: int = 0 compression_ratio: float = 0.0 transforms_applied: list[str] = field(default_factory=list) + diagnostics: list[MessageDecision] | None = None + lossless: bool | None = None + """Whether the output is provably lossless. ``True``/``False`` when + verification ran (``mode='agent'`` or ``verify_lossless=True``); ``None`` + when not checked. ``False`` means a removal marker survived but the message + could not be reverted (should not happen in ``mode='agent'``).""" + reverted_messages: int = 0 + """Messages reverted to their original form because their compression could + not be verified lossless. Only meaningful when verification ran.""" def compress( @@ -224,7 +266,18 @@ def compress( if cfg.savings_profile: apply_agent_savings_profile(cfg, cfg.savings_profile) - pipeline = _get_pipeline() + collect_diagnostics = cfg.diagnostics or os.environ.get("HEADROOM_DIAGNOSTICS", "") == "1" + + agent_mode = cfg.mode == "agent" + if agent_mode: + # Live-agent regime: densify losslessly, never remove. Disable the ML + # text compressor so only deterministic, query-independent lossless + # transforms run (keeps the cached prefix stable across turns). + cfg.kompress_model = "disabled" + cfg.verify_lossless = True + pipeline = _get_agent_pipeline() + else: + pipeline = _get_pipeline() pipeline_extensions = PipelineExtensionManager(hooks=hooks, discover=False) try: @@ -256,6 +309,11 @@ def compress( # alone (position, anomaly) and may drop relevant content. context = _extract_user_query(messages) + # Snapshot the pipeline input so lossless verification can compare each + # message against its pre-compression form and revert any that did not + # round-trip (mode='agent' / verify_lossless). + original_messages = messages + result = pipeline.apply( messages=messages, model=model, @@ -272,6 +330,7 @@ def compress( min_tokens_to_compress=cfg.min_tokens_to_compress, kompress_model=cfg.kompress_model, frozen_message_count=cfg.frozen_message_count, + collect_diagnostics=collect_diagnostics, ) tokens_before = result.tokens_before @@ -294,6 +353,8 @@ def compress( tokens_saved=0, compression_ratio=0.0, transforms_applied=["inflation_guard:reverted"], + # Reverting to the originals is trivially lossless. + lossless=True if cfg.verify_lossless else None, ) routing_markers = summarize_routing_markers(result.transforms_applied) @@ -325,6 +386,28 @@ def compress( if compressed_event.messages is not None: compressed_messages = compressed_event.messages + # Value-factoring (agent mode): hoist repeated low-cardinality column + # values out of densified rows (e.g. file paths in search results). + # Lossless + reversible; applied before verification so the round-trip + # check validates it too. + if agent_mode: + compressed_messages = _apply_value_factoring(compressed_messages) + + # Lossless verification: round-trip every compressed message and revert + # any that is not provably lossless. Guarantees the output is never + # lossy even if a transform slips a removal marker through. + lossless: bool | None = None + reverted = 0 + if cfg.verify_lossless: + compressed_messages, reverted, lossless = _verify_and_revert( + original_messages, compressed_messages + ) + + # Factoring and reverts change the messages after the pipeline counted + # them; recount with the pipeline's own message-counting contract. + if agent_mode or cfg.verify_lossless: + tokens_after = pipeline._get_tokenizer(model).count_messages(compressed_messages) + tokens_saved = tokens_before - tokens_after ratio = tokens_saved / tokens_before if tokens_before > 0 else 0.0 @@ -350,6 +433,9 @@ def compress( tokens_saved=tokens_saved, compression_ratio=ratio, transforms_applied=result.transforms_applied, + lossless=lossless, + reverted_messages=reverted, + diagnostics=result.message_decisions if collect_diagnostics else None, ) except Exception as e: @@ -401,6 +487,39 @@ def compress_spreadsheet( return compress(messages, model=model, model_limit=model_limit, **kwargs) +def densify( + messages: list[dict[str, Any]], + model: str = "claude-sonnet-4-5-20250929", + model_limit: int = 200000, + **kwargs: Any, +) -> CompressResult: + """Lossless, prompt-cache-safe compression for live agents. + + Shorthand for ``compress(messages, mode="agent")``: densifies tool outputs + an agent will read back, with **no removal** (no CCR markers, no dropped + rows, no ML text compression) and a **verified losslessness guarantee** — + any message that cannot be proven to round-trip is reverted to its original. + Because only deterministic, query-independent transforms run, the same tool + output densifies to identical bytes every turn, so a provider prompt-cache + prefix stays stable across the agent loop. + + Inspect :attr:`CompressResult.lossless` (always ``True`` here) and + :attr:`CompressResult.reverted_messages` to see how many messages could not + be densified losslessly. + + Args: + messages: Messages in Anthropic or OpenAI format. + model: Model name (token counting / context limit). + model_limit: Model context window size in tokens. + **kwargs: Forwarded to :func:`compress` (e.g. ``protect_recent``). + + Returns: + CompressResult with losslessly densified messages. + """ + kwargs["mode"] = "agent" + return compress(messages, model=model, model_limit=model_limit, **kwargs) + + def _get_pipeline() -> Any: """Get or create the singleton compression pipeline.""" global _pipeline @@ -423,3 +542,164 @@ def _get_pipeline() -> Any: _pipeline = TransformPipeline() logger.debug("Headroom compression pipeline initialized") return _pipeline + + +def _get_agent_pipeline() -> Any: + """Get or create the singleton live-agent pipeline (lossless densify only). + + A ContentRouter with CCR removal and the ML text compressor disabled, so it + only runs deterministic, query-independent lossless transforms (SmartCrusher + densification, diff compaction). No CacheAligner stage — agent callers pass + the full message list each turn and rely on densification determinism for + prompt-cache stability. + """ + global _agent_pipeline + + if _agent_pipeline is not None: + return _agent_pipeline + + with _pipeline_lock: + if _agent_pipeline is not None: + return _agent_pipeline + + from headroom.transforms import TransformPipeline + from headroom.transforms.content_router import ContentRouter, ContentRouterConfig + + router = ContentRouter( + ContentRouterConfig( + ccr_enabled=False, # never remove content the agent will read + ccr_inject_marker=False, # → emit_opaque_markers off in the engine + enable_kompress=False, # lossless only: no ML text compression + smart_crusher_with_compaction=True, # lossless-first densification + ) + ) + _agent_pipeline = TransformPipeline(transforms=[router]) + logger.debug("Headroom agent (densify-only) pipeline initialized") + return _agent_pipeline + + +def _apply_value_factoring(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Dictionary-encode low-cardinality columns in every densified string leaf. + + Walks message content and runs :func:`factor_values` on any densified block + (no-op for non-densified or already-factored strings). Returns a new list; + inputs are not mutated. + """ + from .transforms.compaction_codec import factor_values, is_compacted + + def walk(node: Any) -> Any: + if isinstance(node, str): + return factor_values(node) if is_compacted(node) else node + if isinstance(node, list): + return [walk(x) for x in node] + if isinstance(node, dict): + return {k: walk(v) for k, v in node.items()} + return node + + return [walk(m) for m in messages] + + +def _verify_and_revert( + original: list[dict[str, Any]], + compressed: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], int, bool]: + """Revert any compressed message that is not provably lossless. + + Compares each compressed message to its original. A message is kept only if + it is unchanged, or every string value it changed is a densified block that + round-trips back to the original (and carries no removal marker). Anything + unverifiable is reverted to the original — so the result is never lossy. + + Returns ``(messages, num_reverted, lossless)`` where ``lossless`` is True + when the final message list contains no surviving removal markers. + """ + from .transforms.compaction_codec import contains_removal_marker + + if len(original) != len(compressed): + # Structure changed unexpectedly — fall back to the originals. + return list(original), len(compressed), True + + out: list[dict[str, Any]] = [] + reverted = 0 + for orig, comp in zip(original, compressed): + if orig == comp or _message_is_lossless(orig, comp): + out.append(comp) + else: + out.append(orig) + reverted += 1 + + lossless = not any(contains_removal_marker(str(m.get("content", ""))) for m in out) + return out, reverted, lossless + + +def _message_is_lossless(orig: dict[str, Any], comp: dict[str, Any]) -> bool: + """True if ``comp`` is a lossless densification of ``orig``. + + Walks both messages in parallel collecting (original, compressed) string + pairs at matching positions; each changed pair must be a densified block + that expands back to the original JSON. Any structural divergence or + unverifiable change returns False (caller reverts). + """ + pairs: list[tuple[str, str]] = [] + if not _collect_string_pairs(orig, comp, pairs): + return False + return all(_string_pair_is_lossless(o, c) for o, c in pairs) + + +def _collect_string_pairs(orig: Any, comp: Any, pairs: list[tuple[str, str]]) -> bool: + """Collect aligned string leaves from two parallel structures. + + Returns False if the structures diverge in shape (different types, lengths, + or dict keys) — a shape we cannot verify and must conservatively revert. + """ + if isinstance(orig, str) and isinstance(comp, str): + if orig != comp: + pairs.append((orig, comp)) + return True + if isinstance(orig, dict) and isinstance(comp, dict): + if orig.keys() != comp.keys(): + return False + return all(_collect_string_pairs(orig[k], comp[k], pairs) for k in orig) + if isinstance(orig, list) and isinstance(comp, list): + if len(orig) != len(comp): + return False + return all(_collect_string_pairs(o, c, pairs) for o, c in zip(orig, comp)) + # Scalars (int/float/bool/None) must match exactly; transforms only touch + # strings, so a changed non-string is unexpected. + return bool(orig == comp) + + +def _string_pair_is_lossless(original: str, compressed: str) -> bool: + """True if ``compressed`` densifies ``original`` reversibly. + + ``original`` is expected to be a JSON array string; ``compressed`` is the + densified block. Lossless iff it carries no removal marker and expands back + to the same parsed JSON. + """ + import json as _json + + from .transforms.compaction_codec import ( + contains_removal_marker, + expand_compacted, + is_compacted, + ) + + if contains_removal_marker(compressed): + return False + if not is_compacted(compressed): + return False + decoded = expand_compacted(compressed) + if decoded is None: + return False + try: + original_obj = _json.loads(original) + except (ValueError, TypeError): + return False + # Compare CANONICAL JSON, not Python ``==``. ``==`` treats True/1/1.0 as + # equal, so a bool<->int or int<->float mis-encode would falsely pass this + # check — the last line of defense before densified output is trusted as + # lossless. ``sort_keys`` keeps the compare insensitive to object key order + # while staying type- and value-exact. + return _json.dumps(original_obj, sort_keys=True, ensure_ascii=False) == _json.dumps( + decoded, sort_keys=True, ensure_ascii=False + ) diff --git a/headroom/compression/__init__.py b/headroom/compression/__init__.py index dd4a9ad2a3..58fa63840d 100644 --- a/headroom/compression/__init__.py +++ b/headroom/compression/__init__.py @@ -1,32 +1,31 @@ """Universal compression with ML-based content detection. -This module provides intelligent, automatic compression that: -1. Detects content type using ML (Magika) -2. Preserves structure (keys, signatures, templates) -3. Compresses content with Kompress -4. Enables retrieval via CCR - -Quick Start: - # One-liner for simple use - from headroom.compression import compress - result = compress(content) - - # Or with configuration - from headroom.compression import UniversalCompressor, UniversalCompressorConfig - - config = UniversalCompressorConfig(compression_ratio_target=0.5) - compressor = UniversalCompressor(config=config) - result = compressor.compress(content) +.. deprecated:: + The package API (``compress``, ``UniversalCompressor``, + ``UniversalCompressorConfig``, ``CompressionResult``, ``MagikaDetector``, + ``ContentType``, ``StructureMask``) is unused inside Headroom and will be + removed in a future release. Use ``headroom.compress()`` for one-call + compression, or ``ContentRouter`` from ``headroom.transforms``. + +``headroom.compression.detector`` is still used by the proxy (Magika preload +at startup) and importing it does not warn. """ -from headroom.compression.detector import ContentType, MagikaDetector -from headroom.compression.masks import StructureMask -from headroom.compression.universal import ( - CompressionResult, - UniversalCompressor, - UniversalCompressorConfig, - compress, -) +from __future__ import annotations + +import warnings +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from headroom.compression.detector import ContentType, MagikaDetector + from headroom.compression.masks import StructureMask + from headroom.compression.universal import ( + CompressionResult, + UniversalCompressor, + UniversalCompressorConfig, + compress, + ) __all__ = [ # Simple API @@ -40,3 +39,45 @@ "ContentType", "StructureMask", ] + +_DEPRECATED_EXPORTS: dict[str, tuple[str, str]] = { + "compress": ("headroom.compression.universal", "compress"), + "UniversalCompressor": ("headroom.compression.universal", "UniversalCompressor"), + "UniversalCompressorConfig": ("headroom.compression.universal", "UniversalCompressorConfig"), + "CompressionResult": ("headroom.compression.universal", "CompressionResult"), + "MagikaDetector": ("headroom.compression.detector", "MagikaDetector"), + "ContentType": ("headroom.compression.detector", "ContentType"), + "StructureMask": ("headroom.compression.masks", "StructureMask"), +} + +# On importlib.reload() the module dict is reused; drop cached exports so the +# next access resolves (and warns) against the current submodules. +for _name in _DEPRECATED_EXPORTS: + globals().pop(_name, None) +del _name + + +def __getattr__(name: str) -> object: + try: + module_name, attr_name = _DEPRECATED_EXPORTS[name] + except KeyError as exc: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + warnings.warn( + f"headroom.compression.{name} is deprecated and will be removed in a future release; " + "use headroom.compress() for one-call compression, or ContentRouter from headroom.transforms.", + DeprecationWarning, + stacklevel=2, + ) + # Warn once, here, rather than again from universal.py's import-time warning. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + module = import_module(module_name) + value = getattr(module, attr_name) + # Cache it, so `from headroom.compression import X` (which looks the name + # up twice) warns once, and later accesses stay quiet. + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/headroom/compression/handlers/code_handler.py b/headroom/compression/handlers/code_handler.py index 9caeec8c8e..3e98f3d96f 100644 --- a/headroom/compression/handlers/code_handler.py +++ b/headroom/compression/handlers/code_handler.py @@ -605,16 +605,24 @@ def _byte_spans_to_char_spans(spans: list[CodeSpan], content: str) -> list[CodeS returned unchanged. Otherwise a byte->char table is built once and every span endpoint is remapped. """ - n_bytes = len(content.encode("utf-8")) - if n_bytes == len(content): + # ASCII fast path: every byte is one character, so tree-sitter's byte + # offsets are already character offsets. ``content.isascii()`` checks + # this in C without allocating, where ``len(content.encode("utf-8"))`` + # built (and discarded) a full UTF-8 copy of the content on every code + # block just to compare lengths. + if content.isascii(): return spans # byte_to_char[b] = index of the character containing byte b; - # byte_to_char[n_bytes] = len(content) so exclusive ends map. + # byte_to_char[n_bytes] = len(content) so exclusive ends map. A + # character's UTF-8 width is derived from its code point (1-4 bytes) + # rather than re-encoding every character individually in the loop. + n_bytes = len(content.encode("utf-8")) byte_to_char = [0] * (n_bytes + 1) byte_pos = 0 for char_idx, ch in enumerate(content): - ch_width = len(ch.encode("utf-8")) + cp = ord(ch) + ch_width = 1 if cp < 0x80 else 2 if cp < 0x800 else 3 if cp < 0x10000 else 4 for b in range(byte_pos, byte_pos + ch_width): byte_to_char[b] = char_idx byte_pos += ch_width diff --git a/headroom/compression/universal.py b/headroom/compression/universal.py index e384826ea8..a32de261ad 100644 --- a/headroom/compression/universal.py +++ b/headroom/compression/universal.py @@ -1,5 +1,10 @@ """Universal compressor with ML-based detection and structure preservation. +.. deprecated:: + Nothing in Headroom uses this module. It will be removed in a future + release. Use ``headroom.compress()`` for one-call compression, or + ``ContentRouter`` from ``headroom.transforms``. + This is the main entry point for compression. It: 1. Detects content type using Magika (ML) 2. Extracts structure using appropriate handler @@ -20,6 +25,7 @@ from __future__ import annotations import logging +import warnings from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -44,6 +50,13 @@ logger = logging.getLogger(__name__) +warnings.warn( + "headroom.compression.universal is deprecated and will be removed in a future release; " + "use headroom.compress() for one-call compression, or ContentRouter from headroom.transforms.", + DeprecationWarning, + stacklevel=2, +) + @dataclass class UniversalCompressorConfig: diff --git a/headroom/config.py b/headroom/config.py index c4edfb71e7..465b3164ef 100644 --- a/headroom/config.py +++ b/headroom/config.py @@ -4,6 +4,7 @@ import fnmatch import json +import re from collections.abc import Iterable from dataclasses import InitVar, dataclass, field from datetime import datetime @@ -228,6 +229,10 @@ class AnchorConfig: "WebSearch", "WebFetch", "headroom_retrieve", + # Caveman's recovery tool: like headroom_retrieve, it returns the stored + # original of something Caveman compressed upstream of us. Compressing it + # again hands the model a lossy copy of the "complete original" (#4010). + "caveman_retrieve", # Copilot CLI's file-read tool (its `Read` equivalent): raw file bytes # the model byte-patches against. "view", @@ -281,6 +286,9 @@ class AnchorConfig: "web_search", "web_fetch", "headroom_retrieve", + # A recovery tool's output must reach the model exactly as stored; even + # the lossless fold (e.g. JSON minify) changes it (#4010). + "caveman_retrieve", # `view` (Copilot CLI file read) must stay BYTE-EXACT: the model produces # line/byte-precise edits against it, and even "lossless" JSON rewrites # or cross-turn dedup folds break old_str matching and force re-reads. @@ -405,8 +413,124 @@ def _load_json_value(raw: Any) -> Any: return raw +# headroom's own retrieval tool. Kept as a literal (rather than imported from +# headroom.ccr.tool_injection) so this leaf module stays free of the ccr +# package's import graph; mirrors CCR_TOOL_NAME there. +_CCR_RETRIEVE_TOOL_NAME = "headroom_retrieve" + +# Orchestrator wrappers. OpenCode V2 Code Mode runs every MCP call inside its +# built-in `execute` tool, and Codex code mode sends calls as `exec` / +# `functions.exec` custom tool calls whose payload is JavaScript. On the wire +# the name is the wrapper -- the inner tool name never reaches the proxy -- so +# when the script invokes the retrieval tool the ccr_retrieve exemption misses +# and the retrieved bytes are re-offloaded into a <> marker the agent +# can never redeem (unresolvable retrieval loop, #3563). +# +# Only a call whose own payload invokes the retrieval tool resolves to it: the +# wrapper is never exempted as a whole (an `execute` call running anything else +# stays compressible), and the match keys on the model-authored call arguments +# -- never on the result content (an `original_content` property is data the +# model may echo, not recovery proof). Tradeoff, same family as the +# mcp____ alias matching above: a wrapper payload that merely mentions +# `headroom_retrieve(` inside a string (e.g. a shell command being exec'd) +# over-protects that one output; losing compression is cheap, losing retrieved +# bytes is not. +# +# Matching runs on decoded script text: the OpenAI wire hands the payload over +# JSON-encoded, where a real newline between the callee and `(` arrives as the +# two characters `\n` and a raw-text scan misses the call. +_ORCHESTRATOR_WRAPPER_NAMES = frozenset({"execute", "exec", "functions.exec"}) + +# JavaScript call expressions inside a wrapper payload: `headroom_retrieve(`, +# `tools.headroom.headroom_retrieve(`, plus bracket access +# `tools["headroom_retrieve"](` / `tools['mcp__headroom__headroom_retrieve'](`. +# Optional chaining counts as an invocation too -- `headroom_retrieve?.()`, +# `tools?.headroom.headroom_retrieve(...)`, `tools["headroom_retrieve"]?.()` +# -- since the tool still runs when present and its bytes need the exemption. +# Decoded script text carries unescaped quotes; the escaped form of a raw +# payload is tolerated too. +_JS_CALL_CHAIN_RE = re.compile( + r"(? list[str]: + """Every string nested in a decoded payload (dicts and lists walk + through); scalars contribute nothing.""" + if isinstance(value, str): + return [value] + if isinstance(value, dict): + items: Iterable[Any] = value.values() + elif isinstance(value, (list, tuple)): + items = value + else: + return [] + strings: list[str] = [] + for item in items: + strings.extend(_decoded_strings(item)) + return strings + + +def _orchestrator_invokes_retrieve(name: str, arguments: Any) -> bool: + """True when an orchestrator wrapper's payload invokes the retrieval tool. + + See the comment above ``_ORCHESTRATOR_WRAPPER_NAMES``. ``arguments`` is the + model-authored call payload: JSON text on the OpenAI wire, a decoded dict on + the Anthropic wire, raw JavaScript for a custom_tool_call. JSON text is + decoded before matching so a script newline is seen as a real newline. Any + other value fails closed (the call keeps its wrapper name). + """ + if name not in _ORCHESTRATOR_WRAPPER_NAMES: + return False + texts: list[str] + if isinstance(arguments, str): + decoded = _load_json_value(arguments) + if isinstance(decoded, dict): + texts = _decoded_strings(decoded) + elif decoded is None: + # Not JSON: a raw JavaScript custom_tool_call payload. + texts = [arguments] + else: + # A JSON scalar, string or array: no orchestrator payload shape. + return False + elif isinstance(arguments, dict): + texts = _decoded_strings(arguments) + else: + return False + + def _names_retrieve(candidate: str) -> bool: + # A dotted chain resolves on its last segment + # (`tools.headroom.headroom_retrieve`); is_tool_excluded() then applies + # the usual mcp____ aliases to it. + return is_tool_excluded(candidate.rsplit(".", 1)[-1], (_CCR_RETRIEVE_TOOL_NAME,)) + + for text in texts: + if _CCR_RETRIEVE_TOOL_NAME not in text: + continue + for match in _JS_CALL_CHAIN_RE.finditer(text): + if _names_retrieve(match.group(1)): + return True + for match in _JS_BRACKET_KEY_RE.finditer(text): + key = next(group for group in match.groups() if group) + if _names_retrieve(key): + return True + return False + + def unwrap_tool_call(name: str, arguments: Any) -> tuple[str, Any]: - """Resolve a Hermes deferred ``tool_call`` wrapper to ``(real_name, real_arguments)``. + """Resolve a wrapper tool call to ``(effective_name, effective_arguments)``. + + Two wrapper families resolve here beyond the pass-through: the Hermes + deferred ``tool_call`` bridge (see comment above), and an orchestrator + wrapper (``execute`` / ``exec`` / ``functions.exec``) whose payload invokes + the retrieval tool -- the latter reports the retrieval tool's own name so + its result keeps the ccr_retrieve exemption (#3563). A Hermes bridge over + an orchestrator call (``tool_call`` -> ``execute`` -> script) resolves + through that same orchestrator rule. Non-wrapper names pass through with their arguments unchanged. A batch resolves when every entry names the same tool (Hermes rejects multi-local @@ -416,6 +540,8 @@ def unwrap_tool_call(name: str, arguments: Any) -> tuple[str, Any]: means). """ if name != _HERMES_TOOL_CALL_WRAPPER: + if _orchestrator_invokes_retrieve(name, arguments): + return _CCR_RETRIEVE_TOOL_NAME, arguments return name, arguments payload = _load_json_value(arguments) if not isinstance(payload, dict): @@ -444,6 +570,11 @@ def unwrap_tool_call(name: str, arguments: Any) -> tuple[str, Any]: # Per-call arguments are only meaningful for a single call; a batch keeps # the wrapper payload so nothing downstream reads one entry as the whole. real_arguments = entries[0].get("arguments") if len(entries) == 1 else arguments + if _orchestrator_invokes_retrieve(real_name, real_arguments): + # Hermes bridge over an orchestrator wrapper (`tool_call` -> `execute` + # -> script): the effective call is the orchestrator's, so its + # retrieval check decides the name; the bridge alone is never exempted. + return _CCR_RETRIEVE_TOOL_NAME, real_arguments return real_name, real_arguments @@ -528,13 +659,14 @@ class ReadMaturationConfig: roughly 13x its size. The only cache-safe moment to shrink it is BEFORE it is ever cache-written. - Mechanics: a fresh large Read is held out of the provider prefix - cache (the trailing cache breakpoint is relocated to just before it) - while its file is ACTIVE, stays verbatim the whole time the model is - working with it, and matures into a CCR-backed marker once the file - has been quiet for `quiesce_turns`. Only that final compressed form - ever enters the cache. No cached byte is ever mutated — there is - nothing to bust. + Mechanics: a fresh large Read is held verbatim while its file is + ACTIVE, so the model sees the full text the whole time it is working + with it, and matures into a CCR-backed marker once the file has been + quiet for `quiesce_turns`, if it is still outside the provider-cached + prefix. The held Read is cached with its turn (the client's + breakpoints stay in place), so in a warm session it usually stays + verbatim: re-writing everything after a cached Read costs more than + the Read saves. Activity-based (not a fixed hold window) because the audit-reads simulation showed touch gaps are fat-tailed: next-touch p50 is 4 @@ -923,6 +1055,22 @@ class CachePrefixMetrics: previous_hash: str | None = None # Previous hash for comparison (None = first request) +@dataclass +class MessageDecision: + """Per-message compression decision recorded in diagnostics mode. + + Collected by ContentRouter when ``collect_diagnostics=True`` is passed + as a kwarg (set automatically when ``CompressConfig.diagnostics`` is True + or ``HEADROOM_DIAGNOSTICS=1`` is set in the environment). + """ + + message_index: int + role: str + tokens_before: int + tokens_after: int + action: str + + @dataclass class TransformResult: """Output of a transform operation.""" @@ -937,6 +1085,7 @@ class TransformResult: cache_metrics: CachePrefixMetrics | None = None # Populated by CacheAligner timing: dict[str, float] = field(default_factory=dict) # transform_name → ms waste_signals: WasteSignals | None = None # Detected waste in original messages + message_decisions: list[MessageDecision] = field(default_factory=list) @property def transforms_summary(self) -> dict[str, int]: diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index ac44b75e94..a1fe3554e5 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -15,7 +15,7 @@ from contextvars import ContextVar from ctypes import wintypes from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any from urllib import error as urllib_error @@ -26,6 +26,7 @@ from headroom._subprocess import run from headroom.copilot_linux_secret import read_copilot_oauth_token as read_linux_secret_token from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_macos_keychain_token +from headroom.offline import guard_egress from headroom.proxy import ssl_context as proxy_ssl_context logger = logging.getLogger(__name__) @@ -79,8 +80,20 @@ def _urlopen(request: urllib_request.Request, *, timeout: float) -> Any: - """Open a GitHub request with Headroom's configured corporate trust roots.""" + """Open a GitHub request with Headroom's configured corporate trust roots. + + Every GitHub call in this module bottoms out here, so this is also the + backstop for the air-gap switch. The four call sites guard individually + too — they can name the specific step ("device-flow authorisation", + "token exchange") in the refusal, which this frame cannot — but a fifth + call site added later inherits the refusal from here whether its author + thought about ``HEADROOM_OFFLINE`` or not. + """ + guard_egress( + "GitHub Copilot authentication", + urlparse(request.full_url).hostname or request.full_url, + ) context = proxy_ssl_context.build_urlopen_context() if context is not None: return urllib_request.urlopen(request, timeout=timeout, context=context) @@ -268,17 +281,6 @@ 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 @@ -591,9 +593,19 @@ def _parse_expiry(value: Any) -> float | None: pass try: normalized = raw.replace("Z", "+00:00") - return datetime.fromisoformat(normalized).timestamp() + parsed = datetime.fromisoformat(normalized) except ValueError: return None + # A timezone-naive ISO string (no `Z`, no offset) must be read as UTC, + # not the host's local zone. `datetime.timestamp()` assumes local time + # for naive datetimes, so on a non-UTC box the same expiry resolves to a + # different epoch — hours early or late — silently expiring a live token + # or trusting a dead one. Every other ISO parser in the codebase treats + # naive as UTC (see telemetry/traffic_learner, proxy/memory_rank_policy); + # match that here. + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() return None @@ -689,6 +701,7 @@ def start_copilot_device_authorization( """Start the GitHub Copilot OAuth device-code flow.""" urls = _github_oauth_urls(domain) + guard_egress("GitHub Copilot device-flow authorisation", domain) body = urlencode({"client_id": COPILOT_CHAT_OAUTH_CLIENT_ID, "scope": "read:user"}).encode( "utf-8" ) @@ -720,6 +733,7 @@ def poll_copilot_device_authorization( """Poll GitHub until the device-code OAuth flow returns an access token.""" urls = _github_oauth_urls(domain) + guard_egress("GitHub Copilot device-flow authorisation", domain) deadline = time.time() + max(1, expires_in) poll_interval = max(1, interval) while time.time() < deadline: @@ -808,7 +822,9 @@ def read_cached_oauth_token() -> str | None: return None -def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]: +def iter_oauth_token_candidates( + *, include_platform_secret_stores: bool = True +) -> list[CopilotTokenCandidate]: """Return reusable token candidates in safest-first discovery order.""" candidates: list[CopilotTokenCandidate] = [] @@ -834,6 +850,38 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]: ) ) + if include_platform_secret_stores: + candidates.extend(_platform_secret_store_oauth_token_candidates()) + + candidates.extend(_read_file_oauth_token_candidates()) + + for env_var in _GENERIC_GITHUB_TOKEN_ENV_VARS: + token = os.environ.get(env_var, "").strip() + if token: + candidates.append( + CopilotTokenCandidate( + token=token, + source=f"env:{env_var}", + confidence="generic-github", + ) + ) + + gh_token = _read_gh_cli_oauth_token() + if gh_token: + candidates.append( + CopilotTokenCandidate( + token=gh_token, + source="gh-cli", + confidence="generic-github", + ) + ) + + return _dedupe_token_candidates(candidates) + + +def _platform_secret_store_oauth_token_candidates() -> list[CopilotTokenCandidate]: + """Return OAuth candidates from platform credential stores.""" + candidates: list[CopilotTokenCandidate] = [] windows_copilot_token = _read_windows_copilot_cli_oauth_token() if windows_copilot_token: candidates.append( @@ -863,31 +911,7 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]: confidence="high", ) ) - - candidates.extend(_read_file_oauth_token_candidates()) - - for env_var in _GENERIC_GITHUB_TOKEN_ENV_VARS: - token = os.environ.get(env_var, "").strip() - if token: - candidates.append( - CopilotTokenCandidate( - token=token, - source=f"env:{env_var}", - confidence="generic-github", - ) - ) - - gh_token = _read_gh_cli_oauth_token() - if gh_token: - candidates.append( - CopilotTokenCandidate( - token=gh_token, - source="gh-cli", - confidence="generic-github", - ) - ) - - return _dedupe_token_candidates(candidates) + return candidates def _read_file_oauth_token_candidates() -> list[CopilotTokenCandidate]: @@ -1153,9 +1177,34 @@ def resolve_subscription_bearer_token_details() -> CopilotSubscriptionTokenResol api_url=_subscription_api_url_from_user_info_payload(payload), ) - for candidate in iter_oauth_token_candidates(): + attempted_tokens: set[str] = set() + resolution = _resolve_subscription_oauth_token_candidates( + iter_oauth_token_candidates(include_platform_secret_stores=False), + attempted_tokens=attempted_tokens, + ) + if resolution is not None: + return resolution + + return _resolve_subscription_oauth_token_candidates( + [ + candidate + for candidate in _platform_secret_store_oauth_token_candidates() + if candidate.token not in attempted_tokens + ] + ) + + +def _resolve_subscription_oauth_token_candidates( + candidates: list[CopilotTokenCandidate], + *, + attempted_tokens: set[str] | None = None, +) -> CopilotSubscriptionTokenResolution | None: + """Return the first candidate GitHub accepts for subscription APIs.""" + for candidate in candidates: if not candidate.validate_for_subscription: continue + if attempted_tokens is not None: + attempted_tokens.add(candidate.token) if _is_copilot_api_token(candidate.token): payload = _fetch_copilot_user_info(candidate.token) if payload is not None: @@ -1378,6 +1427,7 @@ def _fetch_copilot_user_info(token: str) -> dict[str, Any] | None: if not token: return None + guard_egress("GitHub Copilot account lookup", urlparse(_user_info_url()).hostname) headers = _copilot_token_exchange_headers(token) request = urllib_request.Request(_user_info_url(), headers=headers, method="GET") try: @@ -1495,6 +1545,10 @@ async def _exchange_token( @staticmethod def _exchange_token_sync(headers: dict[str, str]) -> dict[str, Any]: + guard_egress( + "GitHub Copilot API token exchange", + urlparse(_token_exchange_url()).hostname, + ) request = urllib_request.Request(_token_exchange_url(), headers=headers, method="GET") try: with _urlopen(request, timeout=10.0) as response: diff --git a/headroom/dashboard/__init__.py b/headroom/dashboard/__init__.py index fccff9d219..66c3e36bab 100644 --- a/headroom/dashboard/__init__.py +++ b/headroom/dashboard/__init__.py @@ -1,6 +1,7 @@ """Headroom Dashboard - Real-time proxy monitoring UI.""" import mimetypes +import re from pathlib import Path DASHBOARD_DIR = Path(__file__).parent @@ -49,10 +50,27 @@ def register_static_mime_types() -> None: mimetypes.add_type(mime_type, extension) -def get_dashboard_html() -> str: - """Load the dashboard HTML template.""" +_TEAMS_PERK = re.compile(r"[ \t]*\n?", re.DOTALL) + + +def get_dashboard_html(*, licensed: bool | None = None) -> str: + """Load the dashboard HTML template. + + The Headroom for Teams offer is stripped on a licensed install: it is + already a customer. ``licensed`` is the running proxy's effective licence + state (``create_app`` passes it, covering ``ProxyConfig.license_key``). + When omitted it is resolved from the environment the same way the CLI + does: ``HEADROOM_LICENSE``, or the deprecated ``HEADROOM_LICENSE_KEY``. + """ template_path = TEMPLATES_DIR / "dashboard.html" - return template_path.read_text(encoding="utf-8") + html = template_path.read_text(encoding="utf-8") + if licensed is None: + from headroom.license_env import resolve_license_token + + licensed = bool(resolve_license_token()) + if licensed: + html = _TEAMS_PERK.sub("", html) + return html def get_settings_html() -> str: diff --git a/headroom/dashboard/templates/dashboard.html b/headroom/dashboard/templates/dashboard.html index 8e83a4d849..2b449a7af2 100644 --- a/headroom/dashboard/templates/dashboard.html +++ b/headroom/dashboard/templates/dashboard.html @@ -102,6 +102,8 @@ html:not(.dark) .ring-white\/5 { --tw-ring-color: rgba(0,0,0,0.08); } html:not(.dark) .border-red-900\/30 { border-color: rgba(239,68,68,0.25); } html:not(.dark) .border-emerald-900\/30 { border-color: rgba(16,185,129,0.25); } + /* Light mode: keep the active savings-profile badge readable */ + html:not(.dark) .text-cyan-100 { color: #155e75; } /* Light mode: TTL card color adjustments */ html:not(.dark) .text-cyan-50 { color: #164e63; } html:not(.dark) .text-violet-50 { color: #4c1d95; } @@ -209,8 +211,28 @@

HEADROOM

+ + +