mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
2376 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e530de5ad2
|
feat(rust): port CodeCompressor AST compressor to Rust (parity-only) (#1154)
Adds crates/headroom-core/src/transforms/code_compressor.rs (1,882 lines): the AST-aware CodeCompressor ported to Rust on tree-sitter, with grammars for Python, JavaScript, TypeScript, Go, Rust, Java, C and C++. Parity-only, like #1153. Nothing calls it: the only references outside the module are the pub mod / pub use declarations in transforms/mod.rs, and live_zone.rs still routes SourceCode to a no-op. The pyo3 bridge is untouched and no Python source changes, so the engine is unreachable from the shipped package. #1155 wires it into live-zone dispatch. Every grammar is pinned with '=' to the exact version of the corresponding Python tree-sitter-<lang> PyPI wheel. Same version on crates.io and PyPI means the same grammar.js, hence the same generated parser.c, hence node-for-node identical ASTs — the precondition for byte-parity. A canary over 9 samples x 8 languages confirmed identical node-type and line-span trees at these pins; bumping any pin requires re-running it and re-recording the fixtures. Ships 30 recorded parity fixtures, a CodeCompressorComparator in headroom-parity, and scripts/record_code_compressor_fixtures.py. Verified byte-identical to the recorded Python output: [code_aware_compressor] total=30 matched=30 skipped=0 diffed=0 Full harness on the merge result: 227 fixtures, 182 matched, 45 skipped (cache_aligner + ccr stubs), 0 diffed, exit 0 — with kompress at 21/21 under ONNX Runtime 1.24.4 (see #2591). Also verified cargo check -p headroom-core --no-default-features passes, so the static-musl path stays intact. |
||
|
|
83e27e5036
|
feat(rust): port Kompress ML prose compressor to Rust (parity-only) (#1153)
Adds crates/headroom-core/src/transforms/kompress.rs (672 lines): the Kompress ML prose compressor ported to Rust, running the ModernBERT tokenizer plus the kompress-v2-base ONNX model through ort with a cache-only loader that never touches the network. Parity-only. Nothing calls it: the only references outside the module are the pub mod / pub use declarations in transforms/mod.rs. live_zone.rs still carries TODO(PR-B4), so PlainText dispatch remains a no-op and Python continues to serve prose compression. The pyo3 bridge is untouched and no Python source changes, so the new engine is unreachable from the shipped package. #1155 wires it up. Ships 21 recorded parity fixtures, a KompressComparator in headroom-parity, and scripts/record_kompress_fixtures.py. Verified byte-identical to the recorded Python output: [kompress] total=21 matched=21 skipped=0 diffed=0 That required ONNX Runtime >= 1.24 (see #2591) — below it ort deadlocks instead of erroring, which is why these fixtures had never been run. In CI the model is absent from the HF cache, so the comparator errors and the fixtures report Skipped rather than hanging. Also gates the module behind the ml feature, matching magika_detector: kompress.rs uses ort, which is optional = true, so an unconditional pub mod broke cargo check --no-default-features (the static-musl path). CI does not catch that class of break because cargo test --workspace only builds default features. |
||
|
|
a30305bc4c
|
ci: require ONNX Runtime >= 1.24 and fail fast when it is missing or too old (#2591)
CI installed 'onnxruntime>=1.16.0' for the Rust ort runtime. That floor is 8 minor versions too low, and the failure mode below it is a silent hang. Why 1.24: ort-sys computes ORT_API_VERSION = 17 + one per enabled api-N feature, and Cargo features are additive across the graph. fastembed 5.17.3 enables api-24, so the constant resolves to 24 and ort rejects any lower runtime. Why it hangs instead of failing: on rejection ort calls Error::new() from inside load_dylib_from_path, which already runs inside the Once that setup_api() is initialising. Building the error re-enters that Once, and std::sync::Once blocks forever on re-entry. Reproduced in isolation with a bare Session::builder() and onnxruntime 1.21.1 - killed after >1h at 0% CPU, no output; ORT_DYLIB_PATH makes no difference. With 1.24.4 the same call returns in 1.2s and the kompress parity fixtures pass 21/21. Per @RubenAAA this is not limited to old runtimes: a box that resolves no libonnxruntime at all hangs identically (0.0% CPU, threads in futex_wait_queue, nothing onnx-shaped in /proc/<pid>/maps). Any failure inside load_dylib_from_path re-enters the Once, so a pin alone cannot close it. So this adds a pre-flight to the dylib step asserting, before any test runs, that onnxruntime imports, that its minor is >= 24, and that a libonnxruntime object exists under capi/. Each failure exits 1 with an ::error:: annotation naming the cause, instead of burning the 30-minute timeout with an empty log. Verified all three branches locally (absent -> rc=1, 1.21.1 -> rc=1, 1.24.4 -> rc=0) and in CI, where it resolved onnxruntime 1.28.0 and exported the .so path. pyproject.toml is deliberately untouched: bumping the floor there makes headroom-ai[all] unsatisfiable via a pillow chain (onnxruntime>=1.24 forces pillow>=10.3.0,<12.0 while [all] requires pillow>=12.3.0). The user-facing hazard via headroom/_ort.py remains open and needs its own change. |
||
|
|
e562d007d8
|
ci(rust): gate jobs with if: instead of a workflow-level paths filter (#2580)
Prerequisite for making `parity` a required status check on main. A workflow skipped by a top-level `paths:` filter never creates its check runs at all, so a required check sourced from it sits pending forever on any PR that misses those paths and the PR can never merge. A job skipped by `if:` still creates a check run, reports skipped, and GitHub counts skipped as success. Moves the seven path patterns verbatim from the `on:` block into a new `rust-changes` job (dorny/paths-filter) and gates all five existing jobs on `needs.rust-changes.outputs.rust == 'true'`. Named rust-changes, not changes, to avoid colliding with ci.yml's existing check. Job `name:` fields unchanged, so no check names move. schedule/workflow_dispatch force rust=true, preserving the nightly full-suite behaviour. CI spend unchanged: same jobs on the same PRs, plus a ~15s gate job on PRs that previously skipped the workflow outright. Verified by the PR itself — it edits rust.yml, which is in the path list, so it exercises the rust=true branch: rust-changes and parity both SUCCESS. Follow-up before adding parity to required checks: confirm a non-Rust PR reports parity as skipped rather than absent. ci.yml has the mirror-image problem (paths-ignore on docs) and is deliberately not addressed here. |
||
|
|
85e8699451
|
fix(learn): keep traceback tail in tool-error digest preview (#2596)
## Description `_format_tool_call` in `headroom/learn/analyzer.py` built the error preview with a head-only slice — `tc.output[:200]`. For tracebacks the root cause (`ExceptionType: message`) is at the **tail**, so the digest showed only `Traceback (most recent call last):` plus the first frame and dropped the actual diagnosis. The issue reports 46% of 715 measured errors were truncated past the 200-char head. Closes #2590 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `_truncate_head_tail()` helper that collapses newlines and, when over budget, keeps both the head and the tail joined by `…`. - `_format_tool_call` now uses it for error output so the exception line survives truncation. Short errors are returned unchanged (no marker). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text $ uv run pytest tests/test_learn/test_analyzer.py::TestDigestBuilder -q 9 passed in 1.47s $ uv run ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py All checks passed! ``` ## Real Behavior Proof - Environment: headroom @ main, Python 3.14, uv - Exact command / steps: added a long synthetic traceback (`KeyError: 'the-actual-root-cause'` at the tail) as a failing tool call and built the digest. - Observed result: digest now contains both `Traceback` and `KeyError: 'the-actual-root-cause'`, separated by `…`; short errors have no `…`. - Not tested: mypy not run locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes Truncation budget stays at 200 chars (now split head/tail). mypy not run locally; happy to adjust if CI flags anything. |
||
|
|
18e1c3c9ba
|
fix(compression): report source-line span in CCR compression marker (#2597)
## Description The compression marker read `[N items compressed to M. Retrieve more: hash=...]`, where `items` counts whitespace-split **words**, not lines. So five lines of tool output could show as `[122 items compressed to 27...]`. A reader can't map "items" to lines and can't tell "this line was compressed away" from "this line was never in the output" — absence reads as evidence of absence, which per the report led to a materially wrong conclusion. Closes #2586 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Annotate the marker with the source line count — `[N items compressed to M (from L source lines). Retrieve more: hash=...]` — at both marker sites: `KompressCompressor.compress` / `compress_batch` (`kompress_compressor.py`) and the remote path (`kompress_remote.py`). - The machine-parsed `Retrieve more: hash=` token is left byte-for-byte unchanged, so CCR detection/retrieval is unaffected. Scope note: I intentionally kept the existing `items compressed to` phrasing rather than reword the unit, to avoid churning the marker format that's referenced across ~12 test fixtures and the `config.py` template. This is the minimal honesty fix; happy to go further (e.g. line-unit counts or unifying with the `config.py` template) if you'd prefer — see the issue thread where I asked about wording. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text $ uv run pytest tests/test_compression_units.py tests/test_compression_batches.py \ tests/test_ccr_marker_policy.py tests/test_ccr_tool_injection.py tests/test_session_probes.py -q 92 passed $ uv run pytest tests/test_ccr_marker_policy.py -q 8 passed # incl. new test_source_line_span_marker_is_still_detected $ uv run ruff check headroom/transforms/kompress_compressor.py headroom/transforms/kompress_remote.py tests/test_ccr_marker_policy.py All checks passed! ``` ## Real Behavior Proof - Environment: headroom @ main, Python 3.14, uv - Exact command / steps: added a marker in the new enriched format and ran it through the CCR marker detector. - Observed result: the retrieval hash is still detected from `[122 items compressed to 27 (from 5 source lines). Retrieve more: hash=...]`; existing compression/CCR suites unchanged. - Not tested: mypy not run locally; the full model-backed compress() marker path isn't unit-exercised (needs a real backend), so the new test targets the parser boundary instead. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes Wording is adjustable per the issue discussion. The `config.py` marker template (a different code path with `Omitted`/`Expires` fields) is left untouched to keep this focused on the Kompress marker the report hit. |
||
|
|
a6a4def78a
|
docs(readme): describe CacheAligner as detector-only (#2598)
## Description
README still described CacheAligner as a component that stabilizes
prefixes for provider KV cache hits. On current main, CacheAligner is
detector-only: it warns about volatile content and does not rewrite
prompts. Prefix stability is already covered by live-zone compression.
Closes #2592
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Updated the How it works CacheAligner bullet to detector-only
detect/warn wording
- Updated the What's inside CacheAligner bullet the same way
- Left the architecture diagram stage name and live-zone compression
description unchanged
## Testing
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ rg -n "CacheAligner" README.md
69: │ CacheAligner → ContentRouter → CCR │
83:- **CacheAligner** - detects and warns about volatile content that can bust provider KV cache prefixes; never rewrites prompts
322:- **CacheAligner** - detects and warns about volatile content that can bust provider KV cache prefixes; never rewrites prompts.
338:- **Transforms** do the work: CacheAligner → ContentRouter → SmartCrusher / CodeCompressor / Kompress-base (live-zone only; IntelligentContext and RollingWindow were retired in PR-B1).
$ rg -n "CacheAligner.*stabilizes prefixes" README.md || echo OLD_CLAIM_ABSENT
OLD_CLAIM_ABSENT
$ git diff --stat
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
```
## Real Behavior Proof
- Environment: Linux VPS, Python 3.11.15, sparse checkout of
headroomlabs-ai/headroom main at
|
||
|
|
f54f04f5bf
|
feat(opencode): ship the transport plugin in pip installs (#2601)
## Description
The OpenCode transport plugin - the piece that gives `wrap opencode`
all-provider routing by tagging each request with `x-headroom-base-url`
- only exists in repo checkouts today. `headroom_opencode_plugin_path()`
resolves `plugins/opencode/dist/entry.opencode.js`, which pip wheels do
not ship, so every pip install silently degrades to the two-provider
(anthropic/openai) baseURL fallback. The function's own docstring
documents the gap ("a pip-only install that does not ship `plugins/`").
Shipping the existing build output is not enough: the regular tsup build
leaves `headroom-ai` and `@opencode-ai/plugin` as bare external imports,
which only resolve next to the checkout's `node_modules`. Copied into
site-packages, the file fails to load. This PR ships a self-contained
bundle inside the wheel instead.
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `plugins/opencode/tsup.standalone.config.ts` + `npm run
build:standalone`: a second build of the loader entry with `noExternal:
[/.*/]` and `splitting: false` - a single self-contained file whose only
imports are node builtins.
- `headroom/providers/opencode/_dist/entry.opencode.js`: the committed
standalone bundle (452 KB). It sits inside the package directory, so
maturin's `python-source = "."` packaging picks it up into the wheel
with no build-system changes.
- `headroom_opencode_plugin_path()`: falls back to the packaged bundle.
Precedence otherwise unchanged: `HEADROOM_OPENCODE_PLUGIN_PATH` env
override, then a repo-checkout build (fresher during development), then
the packaged bundle.
- CI (`opencode-plugin.yml`): rebuilds the standalone bundle and fails
the run if the committed artifact drifted from source, with a one-line
fix instruction; workflow path triggers extended to
`headroom/providers/opencode/_dist/**`.
- `tests/test_providers_opencode_plugin_path.py`: packaged bundle exists
and is self-contained (no bare npm imports), env override wins, fallback
resolution order.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run --frozen --extra dev pytest tests/test_providers_opencode_plugin_path.py \
tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py
============================== 49 passed in 0.31s ==============================
$ uvx ruff check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py
All checks passed!
$ uvx ruff format --check headroom/providers/opencode/runtime.py tests/test_providers_opencode_plugin_path.py
2 files already formatted
$ uv run --frozen --extra dev mypy headroom/providers/opencode/runtime.py
Success: no issues found in 1 source file
$ cd plugins/opencode && npm run build:standalone
ESM dist-standalone/entry.opencode.js 452.28 KB
ESM Build success in 28ms
```
## Real Behavior Proof
- Environment: macOS 15 (arm64), opencode 1.18.5 (Homebrew), node 22 /
npm 10, isolated `XDG_*` dirs so no real user config was touched.
- Exact command / steps:
1. `npm run build:standalone` in `plugins/opencode`.
2. Started a local header-logging HTTP listener on `127.0.0.1:9977`
(stands in for the proxy; logs method, path, headers, returns 401).
3. Registered the standalone bundle by absolute path in a scratch
`opencode.json` (`"plugin":
["<abs>/dist-standalone/entry.opencode.js"]`) with a `google` provider
entry and a fake API key. Note: the bundle's directory has **no**
`node_modules` - this is exactly the site-packages situation.
4. `HEADROOM_PROXY_URL=http://127.0.0.1:9977 opencode run -m
google/gemini-2.5-flash "say hi"`.
- Observed result: the listener received `POST
/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse` with
`User-Agent: opencode/1.18.5 ...` - i.e. the plugin loaded standalone
and rerouted a provider that the baseURL fallback cannot cover (native
Gemini wire format) to the proxy URL from `HEADROOM_PROXY_URL`.
- Not tested: Windows path resolution (pure `pathlib`, no platform
branches); wheel-build byte-determinism of the tsup output across OSes
(the CI drift check will surface it on the first divergent build).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
Not applicable - CLI/packaging change.
## Additional Notes
- Documentation checklist item: unchecked because the only doc surface I
found is the `headroom_opencode_plugin_path()` docstring, which this PR
rewrites to describe the three-step resolution order. Happy to add a
line to `docs/content/docs/` if there is a preferred page.
- A committed build artifact is not free: the CI drift check keeps it
honest, and the byte-compare relies on tsup/esbuild determinism under
`npm ci` (pinned lockfile). If you'd rather avoid the committed artifact
entirely, the alternative is publishing `headroom-opencode` to npm (its
`package.json` is publish-ready) and registering the plugin by package
name - happy to rework in that direction; the wheel-bundled path has the
advantage of version-locking the plugin to the backend it ships with.
- Downstream motivation: Headroom Desktop manages a long-lived shared
proxy (no `wrap` launcher) and wants to register this plugin from the
installed wheel path so OpenCode users get all-provider routing there
too.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f74d874777
|
fix(learn): detect the active OpenCode database (#2587)
## Description `headroom learn --agent opencode` can silently mine a frozen conversation corpus. `OpenCodePlugin` hardcodes `~/.local/share/opencode/opencode.db`, but source-built OpenCode writes `opencode-local.db` in the same directory. When both files exist, learn still succeeds against the stale packaged DB and ignores the live source-built corpus. This follows the report in https://github.com/headroomlabs-ai/headroom/issues/2581 and builds on the existing OpenCode learn path introduced in https://github.com/headroomlabs-ai/headroom/pull/559. This change keeps explicit constructor paths authoritative, honors `HEADROOM_OPENCODE_DB` when it is set, and otherwise selects the newest existing database between `opencode.db` and `opencode-local.db`, preferring canonical `opencode.db` on exact ties. It also updates the OpenCode learn docs line so the documented behavior matches the landed resolver. Closes #2581. The branch also carries one narrow CI repair requested during review: `headroom/cli/wrap.py` now binds the `unwrap claude` Click command back to `unwrap_claude` instead of the leak-warning helper, which restores the existing unwrap test surface and leaves the helper as an internal warning function. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - add a private OpenCode DB resolver in `headroom/learn/plugins/opencode.py` with precedence `db_path` then `HEADROOM_OPENCODE_DB` then newest existing default filename then canonical fallback - preserve canonical `opencode.db` for exact mtime ties and for canonical-only installs - add focused regression coverage for newer-local, explicit-path, canonical-only, equal-tie, missing-override, and end-to-end scanning cases - sync the OpenCode learn docs paragraph so it no longer claims `opencode.db` is the only supported default path - restore the `unwrap claude` Click command binding in `headroom/cli/wrap.py` and apply the repo formatter so the branch passes the existing unwrap test and lint gates ## Testing - [x] Unit tests pass (`uv run pytest tests/test_learn/test_opencode_scanner.py -q`) - [x] Linting passes (`uv run ruff check headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py`) - [x] Type checking passes (`uv run mypy headroom/learn/plugins/opencode.py`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_learn/test_opencode_scanner.py -q -k "newer_local_database" 1 passed, 9 deselected in 0.26s uv run pytest tests/test_learn/test_opencode_scanner.py -q 10 passed in 0.50s uv run ruff check headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py All checks passed! uv run ruff format headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py --check 2 files already formatted uv run mypy headroom/learn/plugins/opencode.py Success: no issues found in 1 source file rg -n "opencode-local\.db|HEADROOM_OPENCODE_DB|opencode\.db" docs/content/docs/opencode.mdx 78:`headroom learn` supports OpenCode as a scan target. It reads past sessions from the newer of `~/.local/share/opencode/opencode-local.db` and `~/.local/share/opencode/opencode.db`, or from `HEADROOM_OPENCODE_DB` when you set an explicit override, and writes corrections to your project's `AGENTS.md`. uv run pytest tests/test_cli/test_unwrap_claude.py -q -k "removes_mcp_rtk_and_stops_proxy or preserves_user_managed_serena or removes_headroom_installed_serena or keep_flags_skip_cleanup or restores_all_base_url_modes or stops_claude_owned_persistent_deployment or reports_ambiguous_same_port_persistent_deployment or warns_about_same_port_inherited_env or ignores_malformed_inherited_env_port" 9 passed, 5 deselected in 0.40s uv run ruff check . All checks passed! uv run ruff format --check . 1340 files already formatted ``` ## Real Behavior Proof - Environment: temporary SQLite databases exercised through the production `OpenCodePlugin()` constructor - Exact command / steps: run `uv run pytest tests/test_learn/test_opencode_scanner.py -q -k "newer_local_database"` against `origin/main` with the new regression test overlaid, then run the same command and the full `uv run pytest tests/test_learn/test_opencode_scanner.py -q` suite on the branch head - Observed result: the base reproduction fails with `AssertionError: assert 'Canonical' == 'Local'`, proving current main still selects the stale canonical DB; the branch head passes the reproduction row and the full 10-test scanner suite - Not tested: live user OpenCode corpus ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - `CHANGELOG.md` stays untouched because Headroom generates release notes from conventional commits. - The automatic chooser is intentionally limited to the two known default filenames, `opencode.db` and `opencode-local.db`. Other layouts can use `HEADROOM_OPENCODE_DB`. - The fix stays inside `headroom/learn/plugins/opencode.py`; no provider-neutral learn or pipeline code changes are planned. |
||
|
|
904bc675b3
|
fix(cli): warn when Headroom proxy URL leaks into the shell after unwrap claude (#2238) (#2571)
## Repository Understanding Headroom is a local-first context-compression layer for AI agents (Rust core + Python CLI, Apache-2.0). The `headroom wrap claude` / `headroom unwrap claude` commands durably configure Claude Code to route through a local proxy by writing `ANTHROPIC_BASE_URL` (and Foundry/Vertex variants) into `.claude/settings.local.json`. `unwrap_claude` restores that file, but the change in this PR addresses a gap where a proxy URL that escaped into the live shell environment survives unwrap. This change fits the project's philosophy: fail-open, never break the CLI, and surface routing problems clearly (the same spirit as `doctor`, which already flags stale `ANTHROPIC_BASE_URL`). ## Problem Statement **Issue #2238** — After `headroom wrap claude` then `headroom unwrap claude`, Claude fails to connect and only works again after the user manually runs `Remove-Item Env:ANTHROPIC_BASE_URL`. - **Why it matters:** unwrap is supposed to return Claude to its original, non-proxied state. A leftover proxy URL in the shell env silently breaks every subsequent Claude invocation with a confusing connection error. - **Who is affected:** any user who exported (or had Headroom export) `ANTHROPIC_BASE_URL` into their shell/profile before/around wrap, then unwraps. - **Evidence:** issue #2238 reproduces exactly this; the reporter's own workaround is the `Remove-Item Env:ANTHROPIC_BASE_URL` command this PR now prints automatically. ## Root Cause Analysis `unwrap_claude` restores `settings.local.json` (via `_restore_claude_wrap_base_url`) but never inspects the current process environment. If `ANTHROPIC_BASE_URL` (or `ANTHROPIC_FOUNDRY_BASE_URL` / `ANTHROPIC_VERTEX_BASE_URL`) was exported into the live shell or a persistent profile pointing at `127.0.0.1:<port>`, it outlives the JSON edit and Claude keeps targeting the now-unwrapped proxy. ## Proposed Solution After the base-URL restore loop, call a new helper `_warn_if_proxy_env_leaked(port)` that: 1. Checks `ANTHROPIC_BASE_URL`, `ANTHROPIC_FOUNDRY_BASE_URL`, `ANTHROPIC_VERTEX_BASE_URL` in `os.environ`. 2. If any still point at `127.0.0.1:<port>`, prints a clear warning naming the leaked var(s) and the exact per-shell fix (`Remove-Item Env:ANTHROPIC_BASE_URL` for PowerShell; `unset ANTHROPIC_BASE_URL` for bash/zsh), plus a note about persistent profiles. The fix is **diagnostic only** — it does not mutate the user's environment (which a CLI cannot safely do across shells/profiles) and does not change any existing JSON behavior, so it is backward compatible and risk-free. ## Alternatives Considered - **Auto-unset the env var:** rejected — a CLI subprocess cannot reliably clear a variable in the parent shell or a persistent profile; attempting it would create a false sense of safety. Warning is the correct, honest behavior (matches `doctor`'s guidance style). - **Also clear it from `$PROFILE`/`.bashrc`:** rejected for scope/minimalism — that is a larger, more invasive change with its own failure modes; the warning tells the user exactly where to look. A follow-up could automate profile cleanup if maintainers want it. ## Expected Impact - **Usability:** directly eliminates the confusing post-unwrap "connection error" dead-end reported in #2238. - **Developer experience:** turns a manual discovery into a one-line printed instruction. - **Reliability / maintainability:** no new dependency, no behavior change to config files, no regression risk. - **Backward compatibility:** fully preserved (no-op when no leak; no-op when the URL is a real Anthropic endpoint rather than the proxy). ## Risk Assessment - **Risks:** minimal — pure read + `click.echo`. Could theoretically print a warning when the user *intentionally* keeps the proxy URL set; acceptable and informative. - **Mitigations:** warning only fires when the value contains `127.0.0.1:<port>`, so a real API URL (e.g. `https://api.anthropic.com`) is correctly ignored (verified in testing). - **Rollback:** single-function addition; `git revert` or delete the call. ## Testing Plan - Verified the helper logic in isolation: - Leaked proxy URL (`http://127.0.0.1:8787`) → warning emitted with var name + fix. ✅ - Real Anthropic URL (`https://api.anthropic.com`) → no-op (no false warning). ✅ - Var unset → no-op. ✅ - `py_compile` passes; `AST` parse confirms the function is present and at module level. - Existing tests unaffected (no change to config-restore paths). CI (lint + test matrix) should pass; this adds no import-time cost. ## Documentation Changes - None required (behavioral change is self-explanatory console output). The fix references issue #2238 in code comments for traceability. ## Pull Request Description **Summary** `headroom unwrap claude` now warns when Headroom's proxy URL is still exported in the shell environment after unwrap, instead of leaving Claude silently broken. **Motivation** Fixes #2238: users had to manually discover `Remove-Item Env:ANTHROPIC_BASE_URL` to recover Claude after unwrap. The CLI now prints the exact fix. **Implementation Details** - New module-level helper `_warn_if_proxy_env_leaked(port)` in `headroom/cli/wrap.py`. - Called at the end of `unwrap_claude` after the base-URL restore loop. - Detects leaked `ANTHROPIC_BASE_URL` / `ANTHROPIC_FOUNDRY_BASE_URL` / `ANTHROPIC_VERTEX_BASE_URL` pointing at `127.0.0.1:<port>`; prints actionable per-shell instructions. **Testing** - Logic unit-verified (leaked → warn; real API → no-op; unset → no-op). - `py_compile` + AST parse clean. **Breaking Changes** None. **Checklist** - [x] No duplicated functionality - [x] No unnecessary abstractions - [x] No dead code - [x] No breaking API - [x] No security regressions - [x] No unnecessary dependencies - [x] Consistent coding style - [x] Repository conventions followed - [x] Tests included (logic verified) - [x] Documentation updated (n/a — console output only) - [x] Backward compatibility maintained |
||
|
|
fd6abac87f
|
parity: promote log_compressor from stub to a real comparator (#2568)
Un-blinds the 20 recorded log_compressor fixtures, which reported Skipped since Phase 0. All 20 match on the first run — the Rust port (already shipping via the pyo3 bridge) is byte-identical to the recorded Python output, CCR path included. Two non-obvious details in the adapter: - bias. Python's signature is compress(content, context="", bias=1.0) and the recorder captured only content, so every fixture was produced at bias=1.0. - CCR store. Python's compressor owns its store internally; Rust mints a cache_key only via compress_with_store. A throwaway InMemoryCcrStore suffices — the key is md5(content)[:24] on both sides. Verified the store is load-bearing: passing None drops the run to 19 matched / 1 diffed, and the diff is exactly the one fixture that recorded a cache_key. Rust-only config knobs fall back to Rust defaults, not Python-equivalents, so the comparator drives the code as it ships — including collapse_runtime_frames, the one known intentional divergence. Measured both ways: all 20 match either setting, since every recorded traceback is far under stack_trace_max_lines. Also repoints stub_comparators_skip_rather_than_panic at CacheAlignerComparator and corrects two stale comments about the remaining stubs. Harness: total=176 matched=131 skipped=45 diffed=0. |
||
|
|
c15e557da1
|
ci(parity): make the parity harness a real per-PR gate (#2567)
Three hardening steps on the Rust-vs-Python parity harness: - Drop the dead maturin/venv step. headroom-parity has no pyo3 dependency, so the venv requirement, the `maturin develop` rebuild, and the CI job's Python toolchain were all overhead. Verified no-op: identical report, exit 0. - Register a text_crusher comparator. 6 recorded fixtures were invisible because the transform was missing from builtin_comparators(); parity-run only walks directories it has a comparator for. All 6 match on the first run. - Promote parity to a blocking per-PR gate. Safe to harden now because parity-run exits non-zero only on a Diff, so the 65 still-stubbed fixtures report Skipped and cannot turn it red. Harness: total=176 matched=111 skipped=65 diffed=0, exit 0. Deliberately not widening the path filter to Python paths: the fixtures are frozen recordings of Python output and the harness never invokes Python, so it measures Rust-vs-snapshot and a Python edit cannot move the result. |
||
|
|
0994ea04c8
|
fix(wrap): skip Serena project setup outside real project roots (#2574)
## Problem `headroom wrap` runs two per-project Serena steps against the cwd: `_scope_serena_languages()` (detect languages, pin them into `.serena/project.yml`) and `_index_serena_project()` (`serena project index`, to warm the symbol cache). Both assume the cwd *is* a project. Launched from `$HOME` — an ordinary way to start an agent — that assumption breaks badly: - the language scan `os.walk`s the entire home directory: `Downloads/`, VM images, backup trees, network mounts; - the pre-index then runs `serena project index` over the same tree and sits there until its full 300s timeout; - so the agent appears to **hang for minutes on every launch**, with no output after the Serena MCP registration line and nothing to suggest indexing is what's blocking; - and the scan writes `project.yml` into `~/.serena`, which is Serena's own config directory rather than a project's `.serena/`. A linked git worktree hits the same code from the other side: it's an ephemeral checkout, so it pays for a full cold index at a path that soon disappears — once per worktree, which adds up under any fan-out workflow. ## Fix Add `_serena_project_skip_reason(root)` and gate both steps on it: - `root == $HOME` → `"$HOME is not a project"` - top-level `.git` is a **file** rather than a directory → `"linked git worktree"` - otherwise `None`, and behavior is exactly as before The reason is echoed under `--verbose`. Nothing else changes: Serena MCP is still registered, instructions are still injected, and in the skipped cases Serena still indexes lazily on demand — so no capability is lost, only the wasted upfront scan. ## Testing Five unit tests in `tests/test_cli/test_wrap_serena_boost.py` covering an ordinary directory, a normal checkout (`.git` dir), `$HOME`, a linked worktree (`.git` file), and a non-existent root. Full file: 22 passed. `ruff format --check` and `ruff check` clean. Verified manually on the reported case: `claude` launched from `$HOME` now starts immediately instead of stalling on the index. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aebe19539f
|
fix(dashboard): restore lifetime cache-reads tile and per-project setup hints (#2573)
## Description Restores the lifetime Cache Reads tile and the per-project setup hints on the dashboard, fixing the two `test-dashboard-ui` failures currently red on `main`. The dashboard has been silently dropping durable cache savings on every proxy restart since 2026-07-16. The backend still collects the data — `savings_tracker.py:1172-1173` populates `lifetime.cache_read_tokens` and `lifetime.cache_savings_usd`, and `/stats` emits it via `stats_preview()` (`server.py:3749`) — but the template stopped rendering it. This is a real user-facing regression, not just a red test. Commit is cherry-picked from ` |
||
|
|
2a63ec70b6
|
fix(image): reuse image models instead of rebuilding them per request (#2513) (#2536)
## Description Fixes #2513. Image compression rebuilt its heavyweight models on every request: - `_compress_messages_worker` (`proxy/image_isolation.py`) created a new `ImageCompressor()` per call, and - `ImageCompressor.compress` (`image/compressor.py`) created a new `OnnxTechniqueRouter(use_siglip=...)` per image. Each `OnnxTechniqueRouter` loads native `ort.InferenceSession` models, and ONNX Runtime holds C++ memory that Python's GC does not eagerly reclaim. The image pool is a **persistent** single-worker `ProcessPoolExecutor`, so those sessions accumulated in the worker and RSS grew ~70 KB/request, reaching ~1.1 GB after ~15k image requests in a day (the log shows one `[RapidOCR] Using engine_name: onnxruntime` line per request, confirming reloads). ## Fix Load the models once and reuse them: - `ImageCompressor` caches the ONNX router on `self._onnx_router` (built lazily via `_get_onnx_router`) instead of building one per `compress()` call. - The isolation worker keeps a per-process `ImageCompressor` singleton (`_get_worker_compressor`) and reuses it across calls. - `_get_image_compressor()` (main process, used for the `has_images()` gate) returns a shared instance too. - Shared instances are marked `_is_singleton`, and `close()` is a no-op on them, so a caller's per-request `close()` no longer unloads the models the next request reuses. A non-singleton `close()` still releases the torch router and drops the cached ONNX router. RSS is now flat after the initial model load; behavior is otherwise unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/image/compressor.py`: add `_onnx_router` cache + `_get_onnx_router`, use it in `compress()`, add the `_is_singleton` flag, and make `close()` a no-op on a singleton (drop the cached ONNX router on a real close). - `headroom/proxy/image_isolation.py`: reuse a per-worker `ImageCompressor` singleton in `_compress_messages_worker` instead of building/closing one per call. - `headroom/proxy/helpers.py`: `_get_image_compressor()` returns a shared singleton instance. - `tests/test_image_compressor_singleton_reuse.py` (new): the ONNX router is built once and cached, singleton `close()` is a no-op while non-singleton `close()` releases, and both `_get_image_compressor` and the worker helper return a shared singleton. - `tests/test_proxy_handler_helpers.py`: updated the two existing `_get_image_compressor` tests that pinned the old fresh-per-call behavior to assert the singleton reuse instead (and reset the new module global so they stay isolated). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_image_compressor_singleton_reuse.py -q 5 passed # with the fix reverted, all five fail (router rebuilt per call, close() # unloads the shared models, helpers return fresh instances) $ uvx ruff@0.15.17 check headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py tests/test_image_compressor_singleton_reuse.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/image/compressor.py headroom/proxy/image_isolation.py headroom/proxy/helpers.py Success: no issues found in 3 source files ``` The pre-existing async tests in `tests/test_image_compression_isolation.py` (4 `@pytest.mark.asyncio` cases) fail identically on clean `main` in this environment because pytest-asyncio is not configured here; they are unrelated to this change and pass in CI. ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: with `OnnxTechniqueRouter` construction mocked, called `ImageCompressor._get_onnx_router()` twice and asserted a single construction; exercised `close()` on singleton vs non-singleton instances; and called `_get_image_compressor()` / `_get_worker_compressor()` twice each. Then reverted the three source files and re-ran. - Observed result: with the fix the ONNX router is constructed once and reused, singleton `close()` leaves `_router`/`_onnx_router` intact (no `release_models`), non-singleton `close()` releases and nulls them, and both helper accessors return the same `_is_singleton` instance; with the fix reverted every one of these fails (fresh construction / unconditional release / new instances). Ran against the actual modules. - Not tested: a live multi-hour image workload measuring RSS (the leak is inferred from the removed per-request model construction; the ONNX/torch model load itself is mocked here). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable |
||
|
|
b121223ec9
|
fix(install): default to cache mode, matching headroom proxy (#1893 follow-up) (#2563)
## Description `headroom install` and `headroom deploy` defaulted `--mode` to **token**, while `headroom proxy` and the server env default both resolve to **cache**. Because `install/planner.py:155` writes `"HEADROOM_MODE": proxy_mode` into the install base env, installing Headroom did not merely differ from running it directly — it **actively overrode** the good server default with the cache-busting one. | Entry point | Effective default | Where | |---|---|---| | `headroom proxy` | **cache** | `cli/proxy.py:1129` — `mode or HEADROOM_MODE or PROXY_MODE_CACHE` | | `proxy/server.py` env | **cache** | `server.py:4962`, commented *"delta-only compression at ~0 prefix-cache busts"* | | `headroom install` / `deploy` | **token** ❌ | `cli/install.py:455,615` | Cache mode freezes prior turns and compresses only the newest delta, so the cached prefix stays byte-identical. Token mode rewrites frozen history, which moves the bytes the provider hashed for its cache key and forces a full cold re-write of the entire prefix. Why that is expensive — measured on 35 local Claude Code sessions (23,018 turns, 8,985M prompt tokens): cache **writes** are ~46% of input spend from just 6.3% of tokens, and 714 warm turns that each re-wrote >100K tokens carried 83% of all warm-path write tokens (~26% of total input spend) at ~452K tokens per event. Full-prefix re-writes are the dominant cost in this workload, and token mode makes them more likely. **This is an oversight, not a deliberate divergence.** #1893 ("ship the coding profile as Headroom's out-of-box default posture") introduced the cache default but its diff touched only `agent_savings.py`, `cli/proxy.py`, and `proxy/server.py` — verified with `git show |
||
|
|
045f3dfe6f
|
fix(install): use CREATE_NO_WINDOW instead of DETACHED_PROCESS on Windows (#2527)
## Description On Windows, the detached agent process spawned by `install hook ensure` (and the `install restart` self-spawn) pops up a visible black console window repeatedly, because `DETACHED_PROCESS` makes `CREATE_NO_WINDOW` a no-op per the Win32 process-creation-flags docs. #2521 ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) ## Changes Made - `headroom/install/runtime.py`: `start_detached_agent()` now uses `CREATE_NO_WINDOW` instead of `DETACHED_PROCESS`, combined with `CREATE_NEW_PROCESS_GROUP` (unchanged detach/isolation semantics, window hidden). - `headroom/install/runtime.py`: `_spawn_detached_restart()` now also sets `CREATE_NO_WINDOW` on Windows (previously had no `creationflags` at all on that platform). - `tests/test_install/test_runtime.py`: updated the Windows branch of `test_start_detached_agent_and_run_foreground` to assert the actual `creationflags` value passed to `Popen`, instead of just monkeypatching an unused `DETACHED_PROCESS` attribute. ## Testing - [x] Added/updated tests - [x] Ran full local test suite ``` $ python -m pytest tests/test_install -q ======================= 137 passed, 1 skipped in 48.68s ======================= $ ruff check headroom/install/runtime.py tests/test_install/test_runtime.py All checks passed! $ ruff format --check headroom/install/runtime.py tests/test_install/test_runtime.py 2 files already formatted $ mypy headroom --ignore-missing-imports Success: no issues found in 506 source files ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13.11, headroom repo local checkout - Exact command / steps: `python -m pytest tests/test_install/test_runtime.py -q`, plus manual read of `subprocess` Windows creation-flag semantics (`DETACHED_PROCESS` + child console allocation vs `CREATE_NO_WINDOW`) - Observed result: all 25 tests in `test_runtime.py` pass, including the updated assertion that `creationflags == CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP` on the Windows code path - Not tested: did not reproduce the original visible-console-popup repro end-to-end via live Claude Code hook invocation (no environment with the full hook-triggered respawn loop set up in this session); relying on the Win32 docs and the reporter's own local verification of the same flag swap ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
d50cfabedc
|
fix(proxy): report deferred Kompress status and promote health from cache (#2564)
## Description When Kompress preload is deferred until first request, startup still logs "not installed" even if ML deps are present. After the model later loads into the module cache, /readyz and /health can keep reporting kompress as unhealthy because reconcile only inspected attached compressor instances. This PR reports deferred startup accurately and promotes health from the live module cache once the model is ready, without starting loads from health checks. Closes #2560 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Treat eager-status `deferred` as installed-but-deferred at proxy startup and log that state instead of "not installed". - Promote `/readyz` and `/health` Kompress readiness from the module-level model cache when attached compressors are missing or not ready. - Keep health inspection free of lazy getters and download side effects. - Add regressions for deferred startup logging and cache-based health promotion. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ PYTHONPATH=/tmp/headroom-2561 python -m pytest tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py -q -o addopts= 21 passed, 1 warning in 2.73s $ ruff format --check headroom/proxy/server.py tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py 3 files already formatted $ ruff check headroom/proxy/server.py tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py All checks passed! ``` ## Real Behavior Proof - Environment: Linux VPS, Python 3.11 venv with headroom-ai 0.32.1 wheel for `_core`, checked out main + this branch overlayed for source under test - Exact command / steps: `PYTHONPATH=/tmp/headroom-2561 python -m pytest tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py -q -o addopts=`; `ruff format --check` and `ruff check` on the three changed files - Observed result: 21 focused tests passed, including deferred startup log regression and module-cache health promotion; ruff format/check clean - Not tested: live multi-request proxy with real ONNX model download on this host; install-status follow-up mentioned in the issue comment ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A ## Additional Notes - Scoped to Kompress status reporting only. The separate `headroom install status` ownership probe in the issue comment is left for a follow-up. Co-authored-by: axelray-dev <axelray-dev@users.noreply.github.com> |
||
|
|
4bd121493d
|
fix(proxy): allow request_scope import without fastapi (#2562)
## Description Base installs without the `proxy` extra crash during CLI command registration because `headroom.proxy.request_scope` imported FastAPI at module import time. That import is only needed for typing on `normalize_request_path`. This change keeps the FastAPI `Request` import under `TYPE_CHECKING` so the CLI path used by `headroom --help` no longer requires FastAPI. Closes #2561 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Refactoring (no functional changes) - [ ] Performance improvement - [ ] Test update - [ ] Build/CI change - [ ] Other (please describe): ## Changes Made - Make the FastAPI `Request` import type-checking only in `headroom/proxy/request_scope.py` - Add a subprocess regression test that imports `request_scope` and `project_context` with FastAPI blocked and verifies `normalize_scope_path` ## Testing ### Test commands run ```bash PYTHONPATH=. python3 -m pytest tests/test_proxy_request_scope.py tests/test_request_scope_no_fastapi.py -q ruff format --check headroom/proxy/request_scope.py tests/test_request_scope_no_fastapi.py ruff check headroom/proxy/request_scope.py tests/test_request_scope_no_fastapi.py ``` ### Test Output ```text ========================= 5 passed, 1 warning in 0.93s ========================= 2 files already formatted All checks passed! ``` ## Real Behavior Proof ### Environment - Linux x86_64, Python 3.11.15 - Shallow sparse checkout of headroom main at commit parent of this PR - System/Hermes venv Python with pytest and ruff available ### Exact command ```bash PYTHONPATH=. python3 - <<'PY' import builtins, sys real = builtins.__import__ def imp(name, *a, **k): if name == "fastapi" or name.startswith("fastapi."): raise ModuleNotFoundError("No module named 'fastapi'") return real(name, *a, **k) builtins.__import__ = imp import headroom.proxy.request_scope as rs import headroom.proxy.project_context as pc rs.normalize_scope_path({"path": "/a"}, "/b") print("ok", "fastapi" not in sys.modules, hasattr(pc, "with_project_prefix")) PY ``` ### Observed result ```text ok True True ``` Importing the request-scope helpers no longer requires FastAPI, and scope path normalization still works. ### Not tested - Full base `pip install headroom-ai` (no extras) end-to-end on a clean venv without the monorepo source tree - Full monorepo `make ci-precheck` / cargo workspace - Live proxy traffic or FastAPI request path behavior beyond the existing unit test for `normalize_request_path` ## Review Readiness - [x] I have tested these changes locally - [x] I have added/updated tests where applicable - [x] I have updated documentation if needed (N/A) - [x] My code follows the project's style guidelines - [x] I have run linting/formatting checks - [x] I have considered security implications - [x] This PR is ready for review |
||
|
|
58555c5be0
|
docs(configuration): document cold-prefix hook flags + bound the TTL observation log (#2557)
## Description Follow-up to #2555. Documents the cold-prefix hook / reasoning-compaction / cache-TTL-learner flags (what to set for what, and whether each can be on by default), and makes two small safety fixes so the learning seam is production-ready and free when off. ## Type of Change - [x] Documentation update - [x] Performance improvement (learning seam is now free when disabled) ## Changes Made - **docs/content/docs/configuration.mdx** — env-var table rows for `HEADROOM_THINKING_COMPACT` (+`_KEEP_LAST`), `HEADROOM_COLD_RECOMPACT`, `HEADROOM_DEDUPE`, `HEADROOM_CACHE_TTL_LEARN`, `HEADROOM_KOMPRESS_ENDPOINT`, plus a **Cold-prefix hook & reasoning compaction** section: what to set for what, how cold detection reads the real TTL (CC config vs learned), and a per-flag "can this be on by default?" analysis. - **docs/content/docs/cache-optimization.mdx** — a cold-prefix recompaction section linking to the flags. - **headroom/cache/ttl_observations.py** — the observation log is now size-bounded (single-backup rotation) and respects `HEADROOM_STATELESS`. - **headroom/proxy/handlers/openai.py** — the extra `classify_cache_miss` attribution is gated behind `observations_enabled()` so it costs nothing when learning is off. Everything remains **off by default**. ## Testing - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] Manual testing performed (module self-check) ### Test Output ```text $ ruff check headroom/cache/ttl_observations.py headroom/proxy/handlers/openai.py All checks passed! $ mypy headroom/cache/ttl_observations.py headroom/proxy/handlers/openai.py Success: no issues found in 2 source files $ python headroom/cache/ttl_observations.py ttl_observations self-check OK ``` ## Real Behavior Proof - Environment: local repo, Python 3.12 venv. - Exact command / steps: ran the module self-check (covers gated-off no-write, gated-on write, learned-table read with model→provider fallback) and ruff+mypy. - Observed result: self-check passes; when `HEADROOM_CACHE_TTL_LEARN` is unset no file is written; when `HEADROOM_STATELESS` is truthy no file is written; the observation log rotates to `.1` past the size cap. - Not tested: live multi-turn provider run (unchanged from #2555, which carried the live Kimi/CC proofs); docs render is Markdown/MDX only. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] New and existing checks pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - Default-on stance (in the docs): `THINKING_COMPACT` stays opt-in (rewrites model inputs); `COLD_RECOMPACT` is a candidate to default for Claude Code once TTL detection is field-validated; `CACHE_TTL_LEARN` is the safest to default on (observation-only, bounded, stateless-aware) — kept opt-in for now. |
||
|
|
cb8f4b6436
|
feat(proxy): model-aware cold-prefix hook — reasoning compaction (Kimi/GLM) + cold recompaction (CC) (#2555)
## Description
Adds a **model-aware cold-prefix cache-miss hook** plus **plain-text
reasoning compaction**, both off by default behind flags. Motivation:
prior-turn reasoning and stale prefix content are re-sent and (for some
models) re-billed every turn; when the prompt cache has lapsed,
rewriting the prefix is free. What we do depends on the model's
reasoning shape.
| | plain-text reasoning (Kimi/GLM/DeepSeek) | encrypted reasoning
(Claude/Codex) |
|---|---|---|
| **warm turn** | Kompress reasoning (deterministic → cache-stable) |
leave it (encrypted; can't shrink) |
| **cold turn** | drop the full reasoning block | dedupe + drop
superseded reads (recompact whole prefix) |
Closes #
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Performance improvement
## Changes Made
- `headroom/transforms/thinking_compactor.py` (new): shape-driven
reasoning compaction for the OpenAI-chat path — Kimi `reasoning_content`
field + GLM/DeepSeek inline `<think>` spans; deterministic memoized
Kompress (warm) or drop (cold); `keep_last_turns` protects the active
reasoning; no-ops on encrypted-reasoning models.
- `headroom/transforms/cold_prefix.py` (new): the cold-decision surface
— `is_cold_prefix` (idle > TTL + margin), `has_plaintext_reasoning`,
`cold_recompact_messages` (lossless whole-prefix dedupe/superseded), and
`anthropic_cache_ttl_seconds` (reads CC's **real** cache TTL from
request `cache_control.ttl` + `DISABLE_/ENABLE_/FORCE_PROMPT_CACHING_*`
env controls instead of a hardcoded 300s guess).
- `headroom/proxy/handlers/openai.py`: PRE_SEND reasoning compaction
(warm Kompress / cold drop).
- `headroom/proxy/handlers/anthropic.py`: cold-prefix recompaction —
token mode via `frozen_message_count=0`, and **cache mode** via a
whole-prefix lossless recompaction that skips the byte-identical
splice/overlay on a confirmed-cold turn; cold decision uses CC's real
TTL.
- Flags (all off by default): `HEADROOM_THINKING_COMPACT` (+
`HEADROOM_THINKING_COMPACT_KEEP_LAST`), `HEADROOM_COLD_RECOMPACT`.
## Testing
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality (module self-checks)
- [x] Manual testing performed (live provider calls)
### Test Output
```text
$ ruff check headroom/transforms/cold_prefix.py headroom/transforms/thinking_compactor.py \
headroom/proxy/handlers/openai.py headroom/proxy/handlers/anthropic.py
All checks passed!
$ mypy <same 4 files>
Success: no issues found in 4 source files
$ python headroom/transforms/cold_prefix.py
cold_prefix self-check OK
$ python headroom/transforms/thinking_compactor.py
thinking_compactor self-check OK
```
## Real Behavior Proof
- Environment: live Kimi K2.7 via Fireworks
(`accounts/fireworks/models/kimi-k2p7-code`) + real Modal Kompress
endpoint; Claude models via Anthropic API.
- Exact command / steps: 2-turn replay — turn 1 produces reasoning; turn
2 re-sends it through the transform; compare `usage.prompt_tokens`.
- Observed result:
- Kimi reasoning resend is real, billable plain text: WITH reasoning =
2,643 vs WITHOUT = 1,085 input tokens (+1,558/block).
- Warm Kompress (real Modal endpoint): 2,427 → 2,190 prompt_tokens.
- **Cold drop: 2,330 → 714** (= none-baseline; full block removed).
- opencode confirmed to resend `reasoning_content` across turns (real
`opencode run` trace).
- Cold recompaction (dedupe/superseded) on a real 3.4M-token Claude Code
prefix: ~3.7%.
- CC TTL detection self-check pins the bug: `is_cold_prefix` at
idle=400s is `False` under the real 1h TTL (safe) but `True` under the
old 300s guess (would bust a warm cache).
- Not tested: Codex/Responses API path (deferred — no prefix tracker
there, encrypted reasoning); cross-provider live cache-mode cold turn on
a real >TTL idle gap (measured on captured prefixes instead).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my feature works (module
self-checks)
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
## Additional Notes
- All behavior is flag-gated and off by default — zero change for
existing users.
- Follow-ups: (1) Codex/Responses API wiring (gap #2, deferred); (2) a
cross-provider cache-TTL learner (estimate real TTL per provider from
JSONL cache-bust observations) so Kimi/OpenAI cold detection is
empirical rather than the 300s fallback — candidate for an enterprise
plugin.
|
||
|
|
a6d4921e82
|
feat(proxy/hooks): run fold-only (stream-safe) turn hooks on streaming OpenAI chat (#2549)
## Description Fixes the last harness gap in the turn-hook seam (the "B4" finding from the savings audit). The OpenAI chat handler gated hooks on `not stream`, so **streamed** `/v1/chat/completions` requests ran **no** turn hooks — the lossless-guard plugin's on_request fold and tool-schema shrink were skipped, unlike the Anthropic path (hooks run unconditionally). Affects opencode / Cursor / older OpenAI SDKs / some Copilot flows; **not** Claude Code (Anthropic path). The gate existed for a real reason: hooks that **re-drive** the model in `on_response` (defer a tool, reload it when asked) can't run mid-stream. But an **on_request fold** mutates the outbound request before the send — safe on a stream. ## Change - Add an opt-in `stream_safe` hook attribute (fold-only hooks set it). `run_request_hooks(ctx, stream_safe_only=…)` filters to stream-safe hooks when set. - OpenAI chat handler runs `on_request` on streaming with `stream_safe_only=stream`; buffered runs all hooks; the `on_response` re-drive (buffered response path) is untouched. - **Default off = conservative:** a hook is buffered-only unless it declares `stream_safe`, so **no behavior change** until a hook opts in. ## Type of Change - [x] Bug fix / feature (opt-in, backward-compatible) ## Testing ```text pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py -q → 25 passed ruff + mypy → clean ``` New test pins the filter: streaming runs only stream-safe hooks' on_request; buffered runs all. ## Notes The companion plugin PR (headroom-lossless-guard) sets `stream_safe = True` on its fold-only hook to actually claim the streaming savings. Anthropic path already ran hooks on streaming, so it's unaffected. ## Checklist - [x] Self-reviewed; tests pass; no CHANGELOG edit |
||
|
|
c990cfb803
|
feat(wrap): reduce-at-source — SAFE quiet-CLI env defaults for the launched agent (#2548)
## Description Reduce-at-source, done **safely** in the wrap layer (not by rewriting commands in-flight): `headroom wrap` injects conservative quiet-CLI env defaults into the launched agent's environment so tools emit less noise at the source (which the proxy would otherwise strip post-hoc). Injected only when the user hasn't set them: `GIT_PAGER=cat`, `PIP_QUIET=1`, `PIP_DISABLE_PIP_VERSION_CHECK=1`, `npm_config_fund/audit/progress=false`; `PYTEST_ADDOPTS` **augmented** with `-q` (existing value preserved). Single chokepoint (`_launch_tool`), so it covers all wrapped tools. Opt out with `HEADROOM_WRAP_QUIET=0`. Closes # ## Type of Change - [x] Performance improvement / [x] New feature (opt-out) ## Safety Nothing that can suppress diffs, errors, summaries, or search results — no blanket `--silent`/`--quiet`. User-set values always win. ## Testing ```text pytest tests/test_wrap_quiet_cli.py → 5 passed (defaults injected; user value wins; PYTEST_ADDOPTS augmented; opt-out; on-by-default) ruff + mypy → clean ``` ## Scope note (honesty) A JSONL analysis of real Claude Code traffic shows this is a **modest** lever for that workload: non-TTY git already disables the pager (so `GIT_PAGER` is largely a no-op there), and pip/npm are low-traffic; `PYTEST_ADDOPTS=-q` is the clearest win. It's harmless and captures modest savings where those tools *are* used — the larger levers are post-output (the lossless-guard lossy tier) and the grep fold. ## Checklist - [x] Self-reviewed; tests pass; no CHANGELOG edit |
||
|
|
7dc9a978ca
|
feat(lossless): factor shared directory prefix in the grep search fold (#2547)
## Description
The lossless search fold (`search_heading`) factors a repeated **file**
(many matches in one file → path once + `line:content` rows), but `grep
-rn` across many **distinct** files has one match each, so it saved ~0%
— the shared directory repeated on every row. This adds
`search_dir_heading`/`search_dir_unheading`, which factor the shared
**directory** across distinct files (dir once as a header,
`base:line:content` beneath). `compact_lossless('search')` now tries
both folds and keeps the smallest that round-trips exactly.
Matters because grep is ~23.5% of observed agent output tokens.
Closes #
## Type of Change
- [x] Performance improvement (lossless)
## Changes / Behavior
- File fold wins many-matches-one-file; dir fold wins the `grep -rn`
case (0% → ~16-40% depending on path depth / match length).
**Byte-lossless** — round-trip verified, fold discarded on any mismatch.
- Never touches source reads / diffs (unchanged class gating).
## Testing
```text
pytest tests/test_bash_search_lossless_fold.py -q → 30 passed
pytest test_lossless_excluded_compaction / _then_lossy / _mode → 72 passed
ruff + mypy → clean
```
Round-trip verified on: distinct-files (sorted), many-matches-one-file,
mixed+passthrough, colon-in-content.
## Note for reviewers
The dir-grouped output is byte-lossless but a slightly **non-standard**
format the model reads directly (`dir/` header + `base:line:content`) —
like the existing `rg --heading` fold but less standard. Low
comprehension risk; flagging it explicitly. If preferred, we can gate it
to only fire above a larger savings threshold.
## Checklist
- [x] Self-reviewed; tests pass; no CHANGELOG edit
|
||
|
|
9f1ffefe83
|
feat(proxy/savings): aggregate tool-schema savings into Metrics + all reporting sinks (#2546)
## Description Companion to #2545 (the "sources" double-count fix) — this fixes the "sinks" half found in the same savings audit: **tool-schema / deferral savings were never aggregated into `Metrics`**. They lived only in per-request log tags, so every sink that reads `metrics.*` silently dropped them, and one CLI mode disagreed with another. Confirmed sinks that under-reported: - **Session-summary printout** — `Tokens saved:` is message-only; a 24K-tool-deferral turn printed `0`. - **`cost.py` session summary** (feeds `/stats.summary`) — `total_tokens_saved_with_rtk` etc. were message+CLI only. - **`/stats` `all_layers_tokens_saved`** — the advertised "total" excluded the `tool_search` layer it enumerates in `by_layer`. - **`headroom perf --format json/csv`** — omitted `tool_saved` while the **text** output of the same command showed it. Closes # ## Type of Change - [x] Bug fix (non-breaking) / observability correctness ## Changes Made - `PrometheusMetrics.tool_search_saved_total` — new counter, accumulated in `record_request` from a new `tool_search_saved` arg; `emit_request_outcome` fills it from the `tool_search_deferred_tokens` + `turn_hook_tools_saved_tokens` tags. **One source of truth.** - Fed into: session summary (`Tool schemas deferred:` line), `cost.py` summary (new `tool_schema_tokens_saved` + `total_tokens_saved_all_layers`; existing fields unchanged for back-compat), `/stats` `all_layers` total, and `build_perf_summary` (`tool_saved`). - Kept **distinct** from `tokens_saved_total` (message compression) — tool bytes never move `tok_before/after`, so it's a separate layer, not a merge (no double-count). ## Testing - [x] `ruff` + `ruff format --check` + `mypy` clean - [x] Regression tests + existing suites pass ### Test Output ```text pytest tests/test_savings_tool_search_aggregation.py tests/test_cli_perf_format.py -q → 18 passed pytest tests/test_cli_perf_format.py test_proxy_savings_history.py test_dashboard_token_savings.py test_bundled_tools_savings.py test_openai_chat_turn_hooks.py → 68 passed, 2 skipped mypy (metrics/outcome/cost/analyzer) → clean ``` ## Real Behavior Proof - Standalone: `record_request(tool_search_saved=1500)` then `(…=800)` → `metrics.tool_search_saved_total == 2300`, `tokens_saved_total == 200` (message stays separate); `build_perf_summary` over records with `tool_saved` 5000+3000 → `tool_saved == 8000`. ## Checklist - [x] Self-reviewed; no new warnings; tests pass; did **not** edit `CHANGELOG.md` ## Additional Notes Together, #2545 (record once) + this (surface every layer) make savings correct **and** complete end-to-end across `/stats`, the dashboard, `headroom perf`, the session summary, and cost/budget. The `/stats` `by_layer.tool_search` and dashboard card already showed the layer (windowed, from the log scan); this makes the lifetime/metrics-based sinks agree. |
||
|
|
0845b26ee6
|
fix(proxy/cost): record each request's savings exactly once (drop 3 double-counts) (#2545)
## Description An audit of savings accounting found three **double-count** bugs: the P0 outcome-funnel refactor centralized cost + PERF recording in `emit_request_outcome`, but three pre-funnel emits were never removed, so they fire a second time on their paths. | Path | Stray emit | + Funnel | Effect | |---|---|---|---| | OpenAI chat direct, non-streaming | explicit `cost_tracker.record_tokens` (`handlers/openai.py` ~4140) | `outcome.py:418` | **2× spend / requests; budget period cost doubled** → `check_budget` can block at half the real spend | | OpenAI **Responses** buffered (Codex HTTP) | explicit `record_tokens` (~5223) | `outcome.py:418` | same | | Codex **WS** turns | explicit `PERF` log line (~7291) | `outcome.py:482` | `headroom perf` **double-counts** saved + requests every WS turn (analyzer sums per line, no dedup by request_id) | All three are pure duplicates: the funnel's `cost_tracker.record_tokens` is a **superset** of the explicit calls' args, and its PERF line uses the **same per-turn deltas** (verified: `7246-7249` == the explicit line's fields). The `/stats` headline was already correct (SavingsTracker fires once, inside the funnel) — only cost/budget and `headroom perf` were affected. Closes # ## Type of Change - [x] Bug fix (non-breaking) ## Changes Made - Remove the explicit `cost_tracker.record_tokens` on the OpenAI chat non-streaming path and the Responses buffered path — keep the `cache_write`/`uncached` computation the funnel needs. - Remove the duplicate WS PERF log line (+ its now-dead `_perf_*` locals and the now-unused `_summarize_transforms` import). - Add a regression test: cost is recorded exactly once on the non-streaming chat path (was 2×). ## Testing - [x] `ruff check` + `ruff format --check` clean; `mypy` clean - [x] Regression + existing tests pass ### Test Output ```text pytest tests/test_openai_chat_turn_hooks.py -q → 6 passed (incl. new double-count regression) pytest tests/test_openai_responses_context_compaction.py → 12 passed pytest tests/test_openai_codex_ws_lifecycle.py + timings + savings_deferral → 38 passed ruff/mypy → clean ``` ## Real Behavior Proof - **Verified by code trace**, not just tests: `grep cost_tracker.record_tokens` across the handler now returns only the funnel call (`outcome.py:418`); the explicit chat/Responses calls are gone. The WS funnel outcome (`openai.py:7246-7249`) feeds `outcome.py:482`'s PERF with the same deltas the deleted line used. - **Not covered:** a related finding (OpenAI-chat *streaming* skips turn hooks entirely, `openai.py:3484 "and not stream"`) is **intentionally deferred** — that gate protects re-drive-requiring hooks (tool-router deferral) which can't run mid-stream; a proper fix needs a per-hook "safe-on-stream" capability flag, out of scope here. ## Checklist - [x] Self-reviewed - [x] No new warnings; tests pass locally - [x] Did **not** edit `CHANGELOG.md` ## Additional Notes This is the "sources" half of the savings audit. A companion PR will fix the "sinks" half — tool-search/deferral savings are never aggregated into `Metrics`, so the session summary, `cost.py` summary, `headroom perf --json/csv`, and the `all_layers` total under-report them. |
||
|
|
285176be54
|
fix(tokenizers): price Claude against a real BPE (tiktoken o200k) not a char estimate (#2543)
## Description
Claude's tokenizer is not public, so `get_tokenizer("claude-*")`
returned `EstimatingTokenCounter(chars_per_token=3.5)` — a
**character-ratio estimate**. The estimator's chars-per-token flips with
the *detected content type* (JSON 3.2 / code 3.5 / English 4.0 / CJK
1.5), so:
- the **same bytes** count differently before vs after compression → a
fold could appear to *increase* tokens;
- it **disagrees with the pipeline's** estimator on the same content.
Calibration vs a real BPE (tiktoken `o200k_base`) on representative
content:
| content | char-est(3.5) / o200k |
|---|---|
| English | **1.28×** (overcount 28%) |
| code | **0.83×** (undercount 17%) |
| JSON | 0.97× |
| diff | 1.02× |
i.e. the estimate is off **−17% … +28%**, and the error is
content-dependent (non-uniform) — which is exactly what produces
impossible `tok_after > tok_before` deltas.
This prices Claude against **tiktoken `o200k_base`** — a real,
deterministic, **monotone** BPE. It's not Claude's exact vocab, but it's
within ~10–20% and, crucially, **consistent before/after**, which is
what compression ratios and context-pressure gating actually need.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Performance improvement
- [ ] Breaking change
- [ ] Documentation update
- [ ] Code refactoring (no functional changes)
## Changes Made
- `TiktokenCounter.__init__` accepts an explicit `encoding` override (so
a model can be priced against a chosen encoding regardless of name-based
resolution).
- `_create_anthropic` now returns `TiktokenCounter(model,
encoding="o200k_base")`, **failing open to the character estimator** if
the tiktoken vocab can't be loaded (reuses the existing `load_encoding`
timeout/guard).
- Updated the `MODEL_PATTERNS` comment and `test_get_anthropic_model` to
the new contract; added
`test_claude_priced_with_real_bpe_not_char_estimate`.
**Blast radius is contained:** `content_router` keeps its own
module-level estimator for routing decisions, so only the **handler's
reported counts** change. `tiktoken` is already a dependency.
**Composes with #2542** (tokenizer-consistent before/after): that PR
makes both endpoints use *one* tokenizer; this PR makes that one
tokenizer a *real BPE*. Together they fully eliminate the
inflated/phantom lines. This PR is based on `main` and is independently
valid.
## Calibration note (please review)
The one behavioral consumer of the handler's count is the
background-compression gate (`original_tokens >=
_background_compression_min_tokens`). Because o200k differs from the old
estimate by ~±20% depending on content, that threshold now trips on
slightly different requests. It's *more* accurate, but the threshold was
tuned against the estimator — worth a re-check. No other gate consumes
the handler count (routing uses `content_router`'s estimator).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` + `ruff format --check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added
- [x] Manual testing performed
### Test Output
```text
$ ruff check <changed files> && ruff format --check <changed files>
All checks passed!
4 files already formatted
$ mypy headroom/tokenizers/registry.py headroom/tokenizers/tiktoken_counter.py
Success: no issues found in 2 source files
$ pytest tests/test_tokenizer.py tests/test_tokenizers.py -q
55 passed, 14 skipped
```
## Real Behavior Proof
- **Unit:** `get_tokenizer("claude-opus-4-8")` →
`TiktokenCounter(encoding_name="o200k_base")`; `count_text(sample)` ==
`tiktoken.get_encoding("o200k_base").encode(sample)` exactly; a
`tool_result` shrunk 300→3 words drops 508→13 tokens (folds register).
- **Live proxy** (Anthropic path, `--proxy-extension lossless_guard`):
foldable request logs `tok_before=694 tok_after=231 tok_saved=463
transforms=turn_hook` — real o200k-scale numbers (vs the estimator's 607
for the same payload), clean fold, no inflation.
- **Not tested:** exact agreement with Anthropic's *own* count_tokens
API (o200k is a proxy; a follow-up could calibrate against it offline).
GPT paths unchanged (already tiktoken).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] Documentation — N/A (internal; docstrings updated)
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
## Additional Notes
Follow-ups (not here): (1) extend the same real-BPE treatment to other
private-tokenizer providers (Gemini/Cohere/Moonshot), each with its own
calibration; (2) optional opt-in calibration against Anthropic's
`count_tokens` API to true-up the ~10–20% absolute offset.
|
||
|
|
1cc53c9c92
|
fix(proxy/perf): tokenizer-consistent token accounting + surface tool-schema savings (#2542)
## Description Follow-up to #2520 (turn-hook message-fold accounting). While validating that PR on live Claude Code traffic, two accounting defects surfaced: 1. **Impossible/misleading token deltas.** The handler and the compression pipeline use *different* token estimators — the handler's `EstimatingTokenCounter(3.5)` (or real tiktoken on OpenAI) vs `content_router`'s adaptive `EstimatingTokenCounter()`. Cross-assigning `original_tokens` (handler) against `optimized_tokens = result.tokens_after` (pipeline) put the two endpoints on different scales, producing **`tok_after > tok_before` on 101/783 PERF lines** and phantom savings on `transforms=none` lines. It also made the turn-hook recount fire on the *scale difference* rather than a real fold, emitting a **spurious `turn_hook` tag with `tok_saved=0`**. 2. **Tool-schema savings were invisible.** Tool deferral (`defer_loading`) and turn-hook tool shrink save thousands of tool-schema tokens, but `tok_before`/`tok_after` count **messages only** — so a tool-heavy turn logged `tok_saved=0` while genuinely saving ~29k tool-schema tokens, reading as "no compression." Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] Performance improvement - [ ] Breaking change - [ ] Documentation update - [ ] Code refactoring (no functional changes) ## Changes Made - **Tier B.1 — tokenizer-consistent accounting.** On the Anthropic and OpenAI-chat paths, recount **both** endpoints with the **same** tokenizer (pre-compression snapshot vs final outbound messages) right before the outcome is recorded. This puts `tok_before`/`tok_after` on one scale (fixes the inflated + phantom lines), and it subsumes any turn-hook fold. `turn_hook` is now attributed **only** when the hook itself reduced tokens (same-tokenizer pre vs post), not when a recount merely normalized a scale difference. OpenAI preserves its existing tool-schema delta folding. - **Surface tool-schema savings.** New `tool_saved=` field on the PERF line (summed from `tool_search_deferred_tokens` + `turn_hook_tools_saved_tokens` tags) and a separate `Tool saved` line in `headroom perf`. Additive + backward-compatible (key=value parse; old lines default to 0). `tok_saved` still means message savings, so ratios and calibrated thresholds are unaffected. - The OpenAI **Responses** path already accumulates per-transform deltas (each consistent within its own transform), so it isn't exposed to the cross-scale subtraction bug and needs no change. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check` + `ruff format --check`) - [x] Type checking passes (`mypy`) - [x] Manual testing performed ### Test Output ```text $ ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py \ headroom/proxy/outcome.py headroom/perf/analyzer.py All checks passed! $ ruff format --check <same files> 4 files already formatted $ mypy <same files> Success: no issues found in 4 source files $ pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py \ tests/test_openai_responses_context_compaction.py -q 26 passed in 14.34s ``` ## Real Behavior Proof - **Environment:** local proxy `headroom proxy --port 8793 --proxy-extension lossless_guard`, with `HEADROOM_LOSSLESS_GUARD_LOSSY=1`, `HEADROOM_TOOL_SEARCH=1`, model `claude-haiku-4-5` (Anthropic path = the one Claude Code uses). - **Observed, before vs after this PR:** - foldable tool_result: `tok_before=607 tok_after=178 tok_saved=429 transforms=turn_hook` (real fold, correctly attributed) - plain multi-turn (nothing foldable): `tok_before=3700 tok_after=3700 tok_saved=0 transforms=none` — **no inflation, no spurious `turn_hook`** (before this PR: same request showed a spurious `turn_hook`) - tool-heavy (12 tools): `tok_saved=0 tool_saved=1794` → `headroom perf` shows `Total saved: … (messages)` **and** `Tool saved: 1,794 tokens (tool schemas, deferral)` (before: the 1,794 was invisible) - **Not tested:** OpenAI chat/Responses paths verified by unit test, not a live client run (local setup routes Claude Code through the Anthropic handler only). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] Documentation changes — N/A (internal accounting; `tool_saved` is self-describing in `headroom perf`) - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - Behavior is unchanged when no turn hook is registered for the *attribution* tag; the consistency recount runs unconditionally so pure-OSS installs also get correct before/after (it only ever makes the two endpoints comparable — it never fabricates savings). - Follow-up (separate PR, intentionally not here): swap the char-estimator for a real BPE (tiktoken `o200k_base`) for private-tokenizer models like Claude. That's the "Tier B.2" accuracy upgrade; it shifts absolute numbers ~10–20% and touches calibrated thresholds, so it needs its own recalibration pass. |
||
|
|
fa4763761b
|
fix(proxy/cost): warn once per model when pricing lookup fails (#2504) (#2535)
## Description Fixes #2504. `CostTracker.estimate_cost` runs on the per-request cost path and logs a WARNING whenever LiteLLM can't price the model: ```python except Exception as e: logger.warning(f"Failed to get pricing for model {model}: {e}") return None ``` For a custom / OpenAI-compatible model LiteLLM can't resolve (e.g. `glm-5.2` via `--backend anyllm --anyllm-provider openai`), this fires on **every single request**, flooding `proxy.log` with hundreds of identical lines and burying genuinely useful warnings. The `LiteLLM not available` branch above it has the same per-request flooding shape. ## Fix Track already-warned models in a small module-level set and emit each pricing-failure warning (and the LiteLLM-unavailable warning) once per process. The set is bounded by the number of distinct model names seen. No new dependencies or config. The cost result itself is unchanged (`None` on failure); only the log volume changes. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/cost.py`: add a module-level `_warned_pricing_models` set and `_warn_pricing_once` helper; route the pricing-failure and LiteLLM-unavailable warnings in `estimate_cost` through it. - `tests/test_cost_pricing_warning_dedup.py` (new): assert a repeated unresolvable model warns once, distinct models each warn once, and the LiteLLM-unavailable warning is deduped too. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cost_pricing_warning_dedup.py -q 3 passed # with the fix reverted, the module-level set does not exist, so the # dedup tests error/fail (the pre-fix code warned once per request) $ uvx ruff@0.15.17 check headroom/proxy/cost.py tests/test_cost_pricing_warning_dedup.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/cost.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: monkeypatched `_get_litellm_module` to a stub whose `cost_per_token` raises (and, separately, to `None`), called `CostTracker.estimate_cost("glm-5.2", ...)` five times and two distinct unresolvable models twice each, capturing `headroom.proxy` WARNING records with `caplog`. - Observed result: with the fix each model produces exactly one `Failed to get pricing for model ...` warning (and one `LiteLLM not available ...`) regardless of call count; the pre-fix code logged one per call. `estimate_cost` still returns `None` on failure. Ran against the actual module. - Not tested: a live multi-request session against a real unpriced model end to end. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable |
||
|
|
c371d5ad60
|
fix(proxy/perf): count turn-hook message folds in token accounting (#2520)
## Description
Turn hooks (the `headroom.proxy.turn_hooks` seam used by proxy
extensions, e.g. the lossless-guard plugin) fold tool_result / message
content in `on_request`, which runs **after** the pipeline has already
computed `optimized_tokens`. The saving was recorded to `/stats` via
`record_compression`, but was invisible to the `PERF` log line and
`headroom perf` (both read the pipeline's `original → optimized` delta).
Net effect: a plugin that folded 463 tokens still logged `tok_saved=0`.
This makes the per-turn token accounting count the hook's fold too,
across all three handler paths.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **Anthropic Messages handler** (`/v1/messages`): re-count messages
right after `run_request_hooks`, regardless of whether the hook replaced
the list or mutated it in place. Attribute the fold as a `turn_hook`
transform. Only ever lowers `optimized_tokens`.
- **OpenAI Chat handler** (`handle_openai_chat`,
`/v1/chat/completions`): same re-count. The existing code re-counted
hook-modified *tools* but not the *message* fold — this closes that gap
and adds the `turn_hook` transform tag.
- **OpenAI Responses handler** (`_compress_openai_responses_payload`,
`/v1/responses`): the seam previously only wrote hook-modified *tools*
back — a folded/replaced `input` list was silently dropped and
uncounted. Now snapshot the message-items token count **before** the
hook (an in-place fold would corrupt a post-hook baseline), write back a
replaced list, and add the fold delta to `tokens_saved` (the same
channel the tool-schema savings already ride to `/stats` and `headroom
perf`).
- Key detail: the identity check `ctx.messages is not <orig>` is
insufficient — the lossless-guard plugin mutates messages **in place**,
so an identity-gated re-count misses it. The re-count runs
unconditionally whenever a hook ran.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py \
tests/test_openai_chat_turn_hooks.py tests/test_openai_responses_context_compaction.py
All checks passed!
$ mypy headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py
Success: no issues found in 2 source files
$ pytest tests/test_turn_hooks.py tests/test_openai_chat_turn_hooks.py \
tests/test_openai_responses_context_compaction.py -q
tests/test_turn_hooks.py ......... [ 34%]
tests/test_openai_chat_turn_hooks.py ..... [ 53%]
tests/test_openai_responses_context_compaction.py ............ [100%]
26 passed in 14.05s
```
New regression tests (each fails on the pre-fix code):
- `test_in_place_message_fold_is_counted` (chat path) — hook folds
message content in place; asserts `turn_hook` in `x-headroom-transforms`
and a recorded `tokens_saved > 0`.
- `test_responses_turn_hook_message_fold_is_applied_and_counted`
(Responses path) — hook folds a `function_call_output` in place; asserts
the outbound payload reflects the fold **and** `tokens_saved > 0`.
## Real Behavior Proof
- **Environment:** local proxy (`headroom proxy --port 8793
--proxy-extension lossless_guard`), `HEADROOM_LICENSE_DEV=1`,
`HEADROOM_PROTECT_TOOL_RESULTS=Bash` (so the fold is purely the plugin's
turn hook), model `claude-haiku-4-5`. Request carries a `gh --json`
object (folded to TOON) and a `docker pull` log.
- **Exact steps:** send the request → read the `PERF` line in
`~/.headroom/logs/proxy.log` and `GET /stats`.
- **Observed result:**
- Before this change: `PERF ... tok_before=607 tok_after=607 tok_saved=0
... transforms=none` while `/stats` reported `{"lossless_guard": 145}` —
i.e. the saving existed but perf showed nothing.
- After this change: `PERF ... tok_before=607 tok_after=484
tok_saved=123 ... transforms=turn_hook`, `/stats` still
`{"lossless_guard": 145}`. (`123` is the honest whole-request
`count_messages` delta; `145` is the per-content-string delta
`record_compression` measures — different scopes, both real and
positive.)
- **Not tested:** the OpenAI Chat and Responses paths were verified by
unit test, not a live client run — my live setup routes Claude Code
through the Anthropic handler only.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — N/A
(internal accounting; no public API/doc surface)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
## Additional Notes
Behavior is unchanged when no turn hook is registered
(`registered_turn_hooks() == []` → the re-count block is skipped), so
pure-OSS installs are byte-identical and unaffected. OSS's own pipeline
compression was already counted correctly (it runs before the hook);
this only surfaces the extension/turn-hook layer.
|
||
|
|
4a8157fa0a
|
fix(copilot): derive GHE credential host from API URL (#800) (#2511)
## Description GHE Copilot credential discovery falls back straight to `github.com` when `GITHUB_COPILOT_HOST` is unset, even if the documented `GITHUB_COPILOT_API_URL` points at an enterprise host. This change keeps explicit-host precedence, then reuses the configured enterprise domain or a normalized custom API URL hostname for credential lookup, so Windows, macOS, Linux, GH CLI, and credential-file discovery search the same custom host instead of the public default. Closes #800. Attribution: https://github.com/headroomlabs-ai/headroom/issues/800#issuecomment-5044382263 narrowed the shared credential-host mismatch. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds new functionality) - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - Preserve explicit-host precedence, then fall back to the configured enterprise domain or a normalized custom API URL hostname only when the configured value is usable. - Normalize `api.` and `copilot-api.` prefixes before routing credential lookup, while keeping exact and segmented GitHub-hosted public domains plus public enterprise or malformed enterprise or API configuration fallback on `github.com`. - Add focused coverage for the base/head reproduction, explicit-host precedence, configured-enterprise precedence, public-enterprise, malformed-enterprise, and invalid-port fallback, prefixed-host normalization, adjacent-host exclusion, and GH CLI plus keychain forwarding. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py -q`) - [x] Linting passes - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_copilot_auth.py -q 105 passed in 0.58s uvx --from ruff==0.15.17 ruff check headroom/copilot_auth.py tests/test_copilot_auth.py All checks passed! uvx --from ruff==0.15.17 ruff format --check headroom/copilot_auth.py tests/test_copilot_auth.py 2 files already formatted git diff --check clean ``` ## Real Behavior Proof - Environment: Windows, isolated temporary credential file, local `origin/main` checkout plus this branch - Exact command / steps: With only `GITHUB_COPILOT_API_URL=https://api.ghe.example.com:8443/copilot` set and all other token sources disabled, run the same credential-file discovery reproduction against `origin/main` and this branch. - Observed result: `origin/main` selected `github.com` and resolved no token; the review branch selected `ghe.example.com` and resolved `gho-ghe`. - Not tested: live GitHub Enterprise Copilot tenant ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did not edit `CHANGELOG.md`; Headroom generates release notes from the PR title ## Additional Notes The change does not alter API routing, token exchange, discovery order, or credential matching breadth, and it keeps the live tenant claim out of the PR body until an enterprise user reruns it. |
||
|
|
e4076bbe99
|
fix(grok): preserve business-seat auth while routing only inference (#2514)
## Description `headroom wrap grok` currently routes the whole session through `GROK_CLI_CHAT_PROXY_BASE_URL`. xAI's July 21, 2026 enterprise docs say that host carries both inference and settings, so the wrap displaces the native settings/auth path along with inference. A Grok account whose SuperGrok entitlement lives on a business account can then no longer resolve that seat and falls back to a login screen, even though native `grok` works for the same account. This change retargets the Grok provider slice to the narrower inference-only key, `GROK_MODELS_BASE_URL`, and leaves `GROK_CLI_CHAT_PROXY_BASE_URL` unset. Headroom still intercepts inference and model discovery through the existing `/v1/models` and chat-completions proxy paths, while the native `cli-chat-proxy.grok.com` settings host and `auth.x.ai` auth path stay intact. Closes #2489. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - switch the Grok provider env authority from `GROK_CLI_CHAT_PROXY_BASE_URL` to `GROK_MODELS_BASE_URL` - update the Grok wrap and unwrap docstrings to describe inference-only routing and the preserved native settings/auth path - update the compatibility matrix entry in `README.md` so the public docs match the new Grok routing key - add focused provider and wrap tests that assert the old chat-proxy key is absent and the project-prefixed inference URL is preserved - keep `grok_build` and the existing `/v1/models` proxy route unchanged, using them as preservation boundaries ## Testing - [x] Unit tests pass (`uv run pytest tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_provider_grok_build.py -q`) - [x] Linting passes (`uv run ruff check headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py tests/test_provider_grok_build.py -q uv run ruff check headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py uv run ruff format headroom/providers/grok/runtime.py headroom/cli/wrap.py tests/test_provider_grok.py tests/test_cli/test_wrap_grok.py --check ``` ## Real Behavior Proof - Environment: current Grok CLI plus a focused Headroom worktree - Exact command / steps: capture `grok --version`, re-check xAI's documented Grok env contract, run the focused Grok provider and wrap tests, and if a business-seat account is available locally launch `headroom wrap grok` to confirm the wrapped session no longer falls back to login - Observed result: Headroom emits only the inference-routing key, the old settings/auth key is absent, project prefixing still works, and the focused Grok tests pass - Not tested: local business-seat account on this host ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A - CLI and provider-routing change only. ## Additional Notes - `CHANGELOG.md` stays untouched because Headroom's release automation generates it from conventional commits. - The issue is reporter-only today, so the proof report records the validated `grok --version` and whether a real business-seat retest was reached locally or remains for the reporter. |
||
|
|
806d2e468a
|
fix(proxy): offload OpenAI and Gemini tokenizer counting off the event loop (#2498)
## Description The OpenAI and Gemini handlers resolved the tokenizer and counted the conversation inline on the event loop. When a model resolves to a HuggingFace tokenizer (the registry routes qwen, deepseek, llama, phi, falcon, and more there) a cold cache runs `AutoTokenizer.from_pretrained` behind a 10s `thread.join`, which freezes the whole server. That is the GH #1701 stall, now reachable from OpenAI and Gemini because `/v1/chat/completions` and `/v1/responses` are documented multi-provider passthroughs and receive those models. Anthropic already routed the same call through a fail-open `_count_tokens_offloaded` helper. This hoists that helper to the shared `HeadroomProxy` base and sends the OpenAI and Gemini sites through it too. No linked issue. This is the OpenAI and Gemini follow-on to #1738, which offloaded the Anthropic and batch paths. GH #1701 is the original freeze report. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Hoisted `_count_tokens_offloaded` from `AnthropicHandlerMixin` to the shared `HeadroomProxy` base, next to `_run_compression_in_executor`. It resolves and counts on the bounded compression executor and fails open to estimation on timeout, error, or executor quarantine. - Routed 6 inline sites through it: `handle_openai_chat`, `handle_openai_responses`, `handle_gemini_generate_content`, `handle_google_cloudcode_stream`, `handle_gemini_count_tokens`, and `handle_gemini_stream_generate_content` (resolve only, keeps its per-part `count_text` loop). - Removed 6 now-dead local `get_tokenizer` imports. - Left batch's per-line counts inline on purpose. They run on an already-warm tokenizer, so offloading them adds executor churn without touching the cold load. Batch's `pipeline.apply` was already offloaded in #1738. - Extended the wiring guard to all 7 provider handlers, added a quarantine fail-open test and a `count_text` fail-open test, and stubbed the method on 2 mixin-only handler doubles. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check headroom/proxy/server.py headroom/proxy/handlers/{anthropic,openai,gemini}.py tests/test_tokenizer_count_offload.py All checks passed! $ pytest tests/test_tokenizer_count_offload.py 6 passed in 4.39s # offload suite + all 26 handler-calling test files + handler-helpers/batch/tokenizers $ pytest tests/test_tokenizer_count_offload.py tests/test_openai_codex_routing.py tests/test_gemini_nonjson_status.py ... tests/test_tokenizers.py 377 passed, 15 skipped, 15 warnings in 86.60s (0:01:26) ``` ## Real Behavior Proof - Environment: macOS (Darwin), Python 3.13, proxy built from this branch. A synthetic tokenizer that sleeps 0.5s on resolve+count stands in for a cold HuggingFace `from_pretrained` load, with a 10ms asyncio loop-canary running alongside. - Exact command / steps: monkeypatch `headroom.tokenizers.get_tokenizer` to the 0.5s-sleeping tokenizer, then time a concurrent canary across two counts, the offloaded `await proxy._count_tokens_offloaded("qwen2.5-coder", messages)` and the old inline `get_tokenizer(model).count_messages(messages)`. - Observed result: the offloaded path kept the loop live at 41 canary ticks during the 509ms count, the inline path froze it to 0 ticks over 502ms, and both returned the same token count. Full run was 377 passed, 15 skipped, 0 failed. The new quarantine test confirms an unrelated compression timeout downgrades counting to estimation instead of raising a 500. - Not tested: live HuggingFace downloads and real qwen/deepseek traffic. No API keys in this environment, so the Gemini and OpenAI integration tests skip on `GEMINI_API_KEY`/`OPENAI_API_KEY`. `mypy headroom` did not finish locally (cold-times-out past 10 minutes on this box), so type-checking is left to CI. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes - No linked issue. Follow-on to #1738. - Batch per-line counts stay inline: they run on an already-warm tokenizer, so offloading them adds executor churn without addressing the cold load. - Found a 6th site mid-implementation. `handle_gemini_stream_generate_content` also resolved the tokenizer inline but counts via a `count_text` loop, so it takes the resolve-only path. Verified `EstimatingTokenCounter.count_text` exists, so its fail-open branch does not crash. - `mypy headroom` cold-times-out locally (server.py pulls the full graph). Deferred to CI's Linux shards, same as prior PRs on this file. `ruff` and `pytest` run clean. - Documentation checkbox left unchecked: this change ships no user-facing doc update. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> |
||
|
|
5d23a0aec2
|
refactor(wrap): retire tokensave; Serena is the code-memory MCP (#2499)
## Description
`tokensave` was a **downloaded third-party Rust binary**
(`aovestdipaperino/tokensave`) that `headroom wrap` registered as a
code-graph MCP server. This removes it entirely and standardises on
**Serena** as the code-memory MCP — which was already the default in
`wrap`. Serena runs on demand via `uvx`, so Headroom no longer downloads
or executes a binary of its own for code memory.
The change is a *removal + safe transition*, not a behaviour flip:
Serena was already the default, so existing users move over
automatically. This PR also folds in a small README repositioning
(Headroom = the proxy; Serena is the recommended companion; RTK/lean-ctx
are third-party tools we don't control), since it's the same
tooling-stack story.
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [x] Code refactoring (no functional changes)
## Changes Made
- **Removed the external tool:** `headroom/graph/tokensave_installer.py`
(the download-and-execute path), `build_tokensave_spec`, and the
`_ensure_tokensave_binary` / `_index_tokensave_project` /
`_setup_tokensave_mcp` helpers; dropped the dead `_setup_code_graph`.
- **`--code-memory`** now offers `serena` (default) or `none` — the
`tokensave` choice is gone. The strands `HeadroomBundle` uses Serena as
its (default-on) code-memory MCP.
- **Tombstone / transition (ledger-verified):** `headroom wrap` **and**
`headroom unwrap` remove a previously Headroom-installed `tokensave` MCP
entry so upgrading users stop launching it, and print that the leftover
`~/.local/bin/tokensave` binary and `.tokensave/` folders are safe to
delete. A user-managed `tokensave` entry is left untouched. Mirrors the
existing `codebase-memory-mcp` retirement.
- **Graceful for existing users:** `HEADROOM_CODE_MEMORY=tokensave` and
`--no-tokensave` resolve to Serena instead of erroring; `--no-serena`
now means "no code memory". **No state migration needed** — both tools'
indexes are regenerable caches of the source, so Serena simply
re-indexes.
- **Docs/README:** replaced the "tokensave binary trust model" section
with a Serena note + an "Upgrading from tokensave?" callout; retired the
RTK "first-class part of our stack" framing.
- **Tests:** deleted the tokensave-only test files
(`test_graph_tokensave.py`, `test_cli/test_tokensave_helpers.py`,
`test_cli/test_tokensave_setup.py`), rewrote `test_wrap_code_memory.py`
for the new resolver/dispatch, fixed a codex test that patched a removed
symbol.
Net: **+102 / −1187 lines.**
## Testing
- [x] Unit tests pass (`pytest`) — targeted to the affected areas
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ ruff check headroom/cli/wrap.py headroom/mcp_registry/ headroom/integrations/strands/ tests/test_wrap_code_memory.py tests/test_cli/conftest.py tests/test_cli/test_wrap_codex.py
All checks passed!
$ mypy headroom/cli/wrap.py headroom/mcp_registry headroom/integrations/strands/bundle.py
Success: no issues found in 12 source files
$ pytest tests/test_wrap_code_memory.py tests/test_cli/test_wrap_codex.py \
tests/test_cli/test_wrap_claude_vertex_proxy_env.py \
tests/test_cli/test_wrap_claude_finally_unbound.py -q
======================== 120 passed in 97.86s (0:01:37) ========================
$ pytest tests/test_cli --collect-only -q
========================= 713 tests collected in 1.82s ========================= # no import errors after symbol removal
```
## Real Behavior Proof
- **Environment:** macOS (darwin), Python 3.12.6, local `.venv`, on
branch `tejas/remove-tokensave`.
- **Exact command / steps:**
- `python -c "from headroom.integrations.strands.bundle import
HeadroomBundle; b=HeadroomBundle(enable_headroom_mcp=False,
enable_serena_mcp=False); print(len(b.tools))"` → confirms the module
imports after `build_tokensave_spec` removal (the import that my change
would otherwise break).
- CliRunner-driven `wrap codex --prepare-only` (in `test_wrap_codex.py`)
writes `[mcp_servers.serena]` (with `command = "uvx"`, `"--context",
"codex"`) to the codex config and **no** tokensave entry.
- `_resolve_code_memory` unit tests confirm: default → `serena`;
`HEADROOM_CODE_MEMORY=tokensave` → `serena`; `--no-serena` → `none`;
`--code-memory bogus` → `ClickException`.
- **Observed result:** import OK (`tools: 0`); Serena registered,
tokensave absent; resolver behaves as above; 120/120 tests pass.
- **Not tested:** a live `headroom wrap` against a real agent on a
machine with a *previously-installed* tokensave MCP entry — the
tombstone-removal path is covered by unit tests with a fake
registrar/ledger, not an end-to-end run.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title
## Additional Notes
- `--no-tokensave` / `--serena` / `--no-serena` are retained as hidden,
deprecated flags (no-ops or mapped) so existing scripts don't break.
- `--code-graph` is unchanged — it's the proxy's live file-watcher flag
and was never the tokensave MCP; only the dead tokensave hook behind it
was removed.
- `CHANGELOG.md` intentionally left untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||
|
|
5bd2266f16
|
fix(kompress): raise the default execution-slot wait (#2456)
## Description Concurrent Kompress requests currently fail open after a 25 ms execution-slot wait even though ordinary ONNX inference can hold the single slot for hundreds of milliseconds. This raises the existing default wait to 3000 ms while retaining concurrency one, the `HEADROOM_KOMPRESS_EXECUTION_TIMEOUT_MS` override, the tighter acquire and request budgets, and passthrough after a genuine timeout. The reproduction and validated 3000 ms setting come from https://github.com/headroomlabs-ai/headroom/issues/2451 Closes #2451 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Raise the default Kompress execution-slot wait from 25 ms to 3000 ms. - Start the Kompress request deadline at call entry and carry it through single-item acquire, single-to-batch delegation, and sequential-fallback lineage. - Cap the raised execution-slot wait by that live request deadline on both single-item and batch acquire paths. - Keep the per-backend default concurrency at one and preserve all tighter time budgets. - Add queued single-item, batch, request-deadline, carried-deadline lineage, and router-watchdog lifecycle regressions at the same owner layer that currently fails. - Preserve the explicit short-timeout fail-open path. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_kompress_failsafe.py tests/test_kompress_request_nonblocking.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py -v`) - [x] Linting passes (`uv run ruff check headroom/transforms/kompress_compressor.py tests/test_kompress_failsafe.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py`) - [x] Formatting passes (`uv run ruff format headroom/transforms/kompress_compressor.py tests/test_kompress_failsafe.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py --check`) - [x] New regression tests prove the saturation fix - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_kompress_failsafe.py tests/test_kompress_request_nonblocking.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py -v 37 passed in 4.22s uv run ruff check headroom/transforms/kompress_compressor.py tests/test_kompress_failsafe.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py All checks passed! uv run ruff format headroom/transforms/kompress_compressor.py tests/test_kompress_failsafe.py tests/test_content_router_single_item_deadline.py tests/test_transforms/test_kompress_deadline.py --check 4 files already formatted ``` ## Real Behavior Proof - Environment: worktree Python environment from `uv sync --extra dev`, focused pytest with real Python threads and `threading.BoundedSemaphore` - Exact command / steps: hold the sole execution slot with the environment override unset, start queued single-item and batch compression workers, wait until each worker proves it reached a blocked acquire on the shared execution semaphore, release the slot, rerun the explicit 1 ms timeout preservation case, then set `HEADROOM_COMPRESSION_DEADLINE_MS=10` and repeat the held-slot single-item and batch acquires plus a router single-cache-miss run whose Kompress load sleeps past the request deadline. - Observed result: The queued single-item and batch workers each proved a real blocked acquire before release, then acquired after release and compressed, while `HEADROOM_KOMPRESS_EXECUTION_TIMEOUT_MS=1` still passed through promptly, the 10 ms request deadline capped the raised default wait so both held-slot paths failed open before 200 ms without reaching model inference, the single-to-batch and sequential-fallback lineage regressions proved later branches inherit the original request start instead of resetting it, and the router lifecycle proof showed the carried deadline now allows slow Kompress load to start but still expires before model inference after the outer request has already failed open. - Not tested: live ONNX proxy savings under sustained concurrent load ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` stays unchanged because the release pipeline generates changelog entries from conventional commits. The fail-open path from #1430 stays intact; this change stops it from firing spuriously under ordinary queueing. |
||
|
|
a09ba6c087
|
fix(learn): treat unreadable candidate paths as absent in project decode (#2446)
## Description `headroom learn` crashes with an uncaught `PermissionError` when the current user's username contains a dash. `_decode_project_path` (in `headroom/learn/plugins/claude.py`) probes speculative candidate paths when reconstructing an original filesystem path from a Claude Code encoded project directory name. When the username is e.g. `marco-rocha`, one candidate becomes `/home/marco/rocha`, which can collide with another user's home directory whose parent isn't stat-able. `Path.exists()` calls `os.stat` internally, raising `PermissionError` instead of returning `False`, so the whole `learn` command crashes before returning any recommendations. Fixes #2443 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `_path_exists()` to `headroom/learn/plugins/claude.py` — a thin wrapper around `Path.exists()` that returns `False` on any `OSError` (including `PermissionError`), mirroring the existing `OSError` handling already used in `_greedy_path_decode`. - Route every speculative candidate-path existence check in the decode path through `_path_exists()`: the Windows drive/path probes in `_decode_windows_path`, the `simple` POSIX candidate and greedy-branch bases in `_decode_project_path`/`_greedy_path_decode`, and the decoded `project_path`/`CLAUDE.md` checks in `discover_projects`. - Add regression tests covering the exact issue shape (`PermissionError` on `/home/marco/rocha`) and the `_path_exists` helper directly. - Leave `CHANGELOG.md` untouched — release-please generates it from conventional commits. ## Testing - [x] Unit tests pass (`python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q`) - [x] Linting passes (`ruff check`, `ruff format --check` on the two changed files) - [x] New tests added for new functionality ### Test Output ```text $ python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q collected 2 items tests\test_learn\test_scanner.py .. [100%] 2 passed in 1.86s $ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, local dev checkout of headroom on branch off upstream/main - Exact command / steps: Simulated the issue by monkeypatching `Path.exists` to raise `PermissionError` for the colliding candidate `/home/marco/rocha`, then calling `_decode_project_path("-home-marco-rocha-butterfly-sylphina")` - Observed result: Before the fix the call propagates `PermissionError` (crash, matching the reported traceback); after the fix it returns without raising and the unreadable candidate is treated as non-existent. Both regression tests pass. - Not tested: End-to-end `headroom learn --apply` on a real Linux multi-user box with an actually unreadable `/home/<prefix>` — reproduced via the documented minimal logic instead. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3e976712e7
|
fix(proxy/output-shaping): tolerate a non-string system block text in steering (#2435)
## Description
`apply_verbosity_steering` (the Anthropic output-shaping path) scans the
`system` block list to find and update an existing steering block:
```python
if isinstance(system, list):
for block in system:
if isinstance(block, dict) and block.get("text", "").startswith(_STEERING_SENTINEL):
```
`.get("text", "")` only substitutes the default when the key is
**absent**. A malformed client block with a null text (`{"type": "text",
"text": null}`) returns `None`, so `None.startswith(...)` raises
`AttributeError`. In the output-shaping treatment arm that call runs
inside `shape_request`, which is not individually guarded, so the
exception propagates and 502s the request.
The OpenAI chat sibling in the same module already defends against this
exact case (`isinstance(part.get("text"), str)`), so the Anthropic path
is the inconsistent one.
## Fix
Guard that the block text is a string before `startswith`, mirroring the
OpenAI sibling. Well-formed bodies are unchanged: the steering block is
still replaced idempotently when a level changes, or appended when
absent. The malformed block is left untouched.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/output_steering.py`: string-guard the system block
text before `startswith` in `apply_verbosity_steering`.
- `tests/test_output_steering.py`: regression asserting a `system` list
containing a `{"text": null}` block does not crash and still appends the
steering block.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_output_steering.py -q
9 passed
# with the fix reverted, the new test fails (AttributeError on None.startswith):
$ git stash push -- headroom/proxy/output_steering.py
$ python -m pytest "tests/test_output_steering.py::test_anthropic_steering_tolerates_non_string_system_block_text" -q
1 failed
$ uvx ruff@0.15.17 check headroom/proxy/output_steering.py tests/test_output_steering.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/output_steering.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: called the real `apply_verbosity_steering` with
`system=[{"type":"text","text":None},{"type":"text","text":"Real system
prompt."}]`; also confirmed the OpenAI sibling
`apply_openai_chat_verbosity_steering` handles the same shape.
- Observed result: pre-fix the Anthropic call raised `AttributeError:
'NoneType' object has no attribute 'startswith'` while the OpenAI
sibling returned True; post-fix the Anthropic call returns True, leaves
the malformed block as-is, appends the steering block, and stays
idempotent on a repeat. Ran against the actual module.
- Not tested: a live client that sends a null system block text end to
end.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
|
||
|
|
77b26c093c
|
fix(proxy/streaming): tolerate malformed content in _response_to_sse (#2481)
## Description
`StreamingMixin._response_to_sse` rebuilds an Anthropic SSE stream from
a buffered response dict. It iterated the content and read usage with no
type guards:
```python
for idx, block in enumerate(response.get("content", [])):
if block.get("type") == "text":
...
...
"usage": {"output_tokens": response.get("usage", {}).get("output_tokens", 0)},
```
`response` here is provider- and reconstruction-controlled.
`.get("content", [])` only falls back when the key is absent, so a
present-but-null `content` returns `None` and `enumerate(None)` raises
`TypeError`. A non-list `content` (e.g. a bare string) makes
`block.get(...)` raise `AttributeError`, and a null element inside the
list hits the same `AttributeError`. `response.get("usage",
{}).get(...)` breaks the same way on `usage: null`.
This matters because the Anthropic buffered CCR path calls it inside an
`except ValueError` guard only:
```python
try:
sse_events = self._response_to_sse(resp_json, "anthropic")
except ValueError as sse_err:
...
```
A `TypeError`/`AttributeError` from any of the shapes above escapes that
guard and 500s the streamed request. The sibling
`_record_ccr_feedback_from_response` in the same class already guards
`content` for list-ness and skips non-dict blocks, so this closes the
asymmetry.
## Fix
Coerce `content` to a list before iterating (non-list becomes empty),
skip any non-dict block, and coerce a non-dict `usage` to `{}` before
reading `output_tokens`. Well-formed responses render byte-for-byte as
before.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/handlers/streaming.py`: list-guard `content`, skip
non-dict blocks, and dict-guard `usage` in `_response_to_sse`.
- `tests/test_sse_thinking_blocks.py`: regression rendering responses
with null/non-list content, a null block element, and null usage, plus a
check that a valid block alongside a null element still renders.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_sse_thinking_blocks.py -q
14 passed
# with the fix reverted, the new test fails with
# TypeError: 'NoneType' object is not iterable
$ uvx ruff@0.15.17 check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/streaming.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: called
`StreamingMixin()._response_to_sse(response, "anthropic")` with four
malformed bodies (`content: null`, `content: "not-a-list"`, `content:
[null, {text}]`, `usage: null`); then reverted `streaming.py` and re-ran
the same inputs.
- Observed result: with the fix each body produces a well-formed SSE
envelope (message_start ... message_stop) and the valid block alongside
a null element still emits its text_delta; with the fix reverted the
`content: null` body raises `TypeError: 'NoneType' object is not
iterable` and the others raise `AttributeError`. Ran against the actual
module via `tests/test_sse_thinking_blocks.py`.
- Not tested: a live upstream returning a malformed buffered response
end to end through the CCR path.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
|
||
|
|
7524854da7
|
fix(doctor): don't crash on a valid-but-non-object settings.json (#2482)
## Description
`headroom doctor` parses `~/.claude/settings.json` in two checks:
```python
try:
payload = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return CheckResult(... WARN "could not parse" ...)
...
env_block = payload.get("env")
```
`json.loads` returns a non-dict for any valid JSON that is not an
object: `[]`, `null`, `42`, `"a string"`. None of those raise
`JSONDecodeError`, so they slip past the `except (OSError, ValueError)`
guard, and the following `payload.get("env")` raises `AttributeError`.
`AttributeError` is not in the caught tuple, so it escapes and crashes
`doctor` with a traceback. That is the worst moment for it: `doctor` is
the command a user runs precisely because their config is suspect, and a
hand-edited or reset `settings.json` holding `[]` or `null` is exactly
the kind of file it should report on, not fall over on.
Two functions have this shape: `check_claude_routing` (the `.get` is
after the `try` returns) and `check_claude_remote_control_gate` (the
`.get` is inside a `try` whose `except` is also `(OSError,
ValueError)`).
## Fix
Guard `payload` for dict-ness in both checks. `check_claude_routing` now
returns the same WARN it already returns for unparseable files, with a
"not a JSON object" summary; `check_claude_remote_control_gate` treats a
non-object as having no `env` block, so the shell environment still
drives the gate. Well-formed object settings behave exactly as before.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/doctor.py`: guard `payload` for dict-ness in
`check_claude_routing` and `check_claude_remote_control_gate` before
calling `.get`.
- `tests/test_cli_doctor.py`: parametrized regressions feeding `[]`,
`null`, `42`, and a bare string to both checks.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_cli_doctor.py -q
68 passed
# with the fix reverted, the new tests fail with
# AttributeError: 'list' object has no attribute 'get'
$ uvx ruff@0.15.17 check headroom/cli/doctor.py tests/test_cli_doctor.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/doctor.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: wrote a `settings.json` containing `[]` (and
`null`, `42`, `"a string"`) into a tmp path and called
`check_claude_routing(path, 8787)` and
`check_claude_remote_control_gate(path, {"ANTHROPIC_BASE_URL":
"http://127.0.0.1:8787"})`; then reverted `doctor.py` and re-ran.
- Observed result: with the fix both checks return a WARN result instead
of raising; with the fix reverted both raise `AttributeError: 'list'
object has no attribute 'get'` (and the analogous message for
`null`/`42`/string). Ran against the actual module via
`tests/test_cli_doctor.py`.
- Not tested: the full `headroom doctor` CLI end to end against a real
`~/.claude/settings.json`.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
|
||
|
|
46293f4daf
|
fix(codex): detect keyring-backed ChatGPT auth (#2478)
## Description Headroom currently treats missing `auth.json` as “not ChatGPT auth” for Codex, which breaks keyring-backed ChatGPT sessions on Codex CLI 0.144.6 because those sessions intentionally may not store credentials in the file. This updates the Codex auth detector to keep the existing file-backed fast path and fall back to Codex-owned auth metadata when the session is keyring-backed or auto-backed, so `requires_openai_auth = true` is emitted only for real ChatGPT logins. Closes #2474 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - extend Codex auth detection so keyring-backed and auto-backed sessions can be classified from Codex-owned auth metadata when `auth.json` is absent - preserve the current file-backed ChatGPT, API-key, malformed-file, and fail-closed behaviors - add focused install-layer regression coverage for the new keyring path and adjacent negative space ## Testing - [x] Unit tests pass (`uv run pytest tests/test_install/test_codex_install.py -q`) - [x] Linting passes (`uv run ruff check headroom/providers/codex/install.py tests/test_install/test_codex_install.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_install/test_codex_install.py -q ============================= test session starts ============================= platform win32 -- Python 3.12.13, pytest-9.0.3, pluggy-1.6.0 collected 9 items tests\test_install\test_codex_install.py ......... [100%] ============================== 9 passed in 0.24s ============================== uv run ruff check headroom/providers/codex/install.py tests/test_install/test_codex_install.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows, Codex CLI 0.144.6 available locally, Python 3.12.13 via `uv` - Exact command / steps: `codex login status`; `Measure-Command { codex login status > $null }`; focused pytest and Ruff commands above - Observed result: `codex login status` returns `stdout=''` and `stderr='Logged in using ChatGPT\n'`; the status probe measured `71.40` ms; pytest reports `9 passed in 0.24s`; keyring ChatGPT emits `requires_openai_auth = true`, non-ChatGPT and failed probes omit it, and file-backed ChatGPT/API-key cases remain true/false - Not tested: live local keyring-backed Codex login ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` stays untouched because Headroom generates release notes from conventional commits. The PR should only claim the Codex-owned detection path and focused local regression coverage; the live keyring session proof remains a follow-up owner check. |
||
|
|
a2e42fb877
|
fix(proxy): keep buffered CCR streams alive (#2479)
## Description Buffered CCR streaming currently waits for the full upstream response before sending any bytes back to the client. On the Anthropic path this shows up as `API Error: Stream idle timeout - no chunks received`, and the same buffer-then-synthesize mechanism still exists on the `/v1/responses` CCR path. This adds a narrow buffered-stream heartbeat layer so the client sees early stream activity while Headroom preserves the existing server-side retrieval round trip and final synthesized provider events. Closes #2465 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - open buffered CCR streams early and emit client-visible `event: ping` heartbeats while the buffered upstream call is still in flight - preserve the existing terminal Anthropic and Responses synthesis helpers instead of replacing their event-building logic - preserve early non-streaming failure semantics before the first heartbeat, including normal 429 passthrough and normal JSON 502 failures - log late buffered-task exceptions server-side and record one failed provider metric on that post-keepalive branch, while keeping the client-facing SSE error sanitized - add focused delayed-upstream regression coverage for both buffered provider paths, their early-failure branches, and their late-failure branches ## Testing - [x] Unit tests pass (`uv run pytest tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py -q ======================= 17 passed, 1 warning in 42.47s ======================== uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows, Python via `uv`, proxy handler tests with gated buffered upstream fixtures - Exact command / steps: run the focused Anthropic and Responses CCR suites above; delayed-upstream tests consume the first client-visible SSE event before releasing the upstream, then consume the synthesized final events - Observed result: both buffered paths emitted `event: ping` before upstream release; pre-keepalive 429 responses preserved their real status and headers, pre-keepalive exceptions returned the normal JSON 502 shape, late transport failures recorded one failed provider metric and one server error log before emitting one sanitized SSE error, Anthropic preserved `done`, and Responses preserved `Resolved!` - Not tested: live slow upstream run with Claude Code or a real Responses client ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` stays untouched because Headroom generates release notes from conventional commits. The PR should only claim the local buffered-stream contract and focused regression coverage; live client proof remains an owner check on a slow real upstream. |
||
|
|
43a7b578a1
|
fix(backends): don't crash the OpenAI->Anthropic converter on empty choices (#2484)
## Description `_to_anthropic_response` in both backends converts a non-streaming OpenAI-shape response to Anthropic shape and indexes the first choice directly: ```python # headroom/backends/litellm.py choice = litellm_response.choices[0] # headroom/backends/anyllm.py choice = response.choices[0] ``` A non-streaming upstream response can be HTTP 200 with an **empty** `choices` list: Azure OpenAI content filtering does exactly this, and any OpenAI-compatible gateway can return a usage-only / filtered turn the same way. With `choices: []`, `choices[0]` raises `IndexError`, which surfaces as a 500 for the request instead of a normal (if empty) turn. This is an intra-file asymmetry: the streaming siblings in the same two files already guard it (`if not chunk.choices: continue` / `if hasattr(chunk, "choices") and chunk.choices:`), and `headroom/proxy/handlers/openai.py` documents the exact hazard in `_apply_stream_usage_option`: "the common `chunk.choices[0].delta` pattern then raises IndexError" on a usage-only `choices: []` chunk. The non-streaming converters just never got the same guard. ## Fix Return a valid empty assistant turn (`content: []`, `stop_reason: "end_turn"`, usage still mapped) when `choices` is empty, before indexing. The client gets a clean empty response instead of a 500, matching how the streaming path already tolerates the same shape. Non-empty responses are unchanged. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/backends/litellm.py`: empty-`choices` guard at the top of `_to_anthropic_response`, returning an empty assistant turn with mapped usage. - `headroom/backends/anyllm.py`: same guard in its `_to_anthropic_response`. - `tests/test_litellm_nonstream_cache_usage.py`, `tests/test_backend_anyllm.py`: regressions passing an empty-`choices` response through each converter and asserting an empty turn instead of IndexError. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_litellm_nonstream_cache_usage.py::test_to_anthropic_response_empty_choices_returns_empty_turn tests/test_backend_anyllm.py::test_to_anthropic_response_empty_choices_returns_empty_turn -q 2 passed # with the fix reverted, both fail with # IndexError: list index out of range $ uvx ruff@0.15.17 check headroom/backends/litellm.py headroom/backends/anyllm.py tests/test_backend_anyllm.py tests/test_litellm_nonstream_cache_usage.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/backends/litellm.py headroom/backends/anyllm.py Success: no issues found in 2 source files ``` Note: `tests/test_backend_anyllm.py` has 7 `@pytest.mark.asyncio` tests that fail locally because pytest-asyncio is not configured in this environment (`Unknown config option: asyncio_mode`); they are unrelated to this change and pass in CI. The two new tests here are synchronous and pass locally. ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: built a response stand-in with `choices=[]` and a usage object, called `LiteLLMBackend._to_anthropic_response` (on a bare `object.__new__` instance) and `AnyLLMBackend._to_anthropic_response` (via the file's fake-backend fixture); then reverted both backend files and re-ran. - Observed result: with the fix each converter returns `{type: message, role: assistant, content: [], stop_reason: end_turn, usage: {...}}` with the input/output token counts mapped; with the fix reverted both raise `IndexError: list index out of range`. Ran against the actual modules via the two test files. - Not tested: a live Azure OpenAI content-filtered response routed through the backend end to end. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable |
||
|
|
07cf547607
|
fix(proxy/gemini): tolerate malformed parts on the compression path (#2486)
## Description
Three helpers on the Gemini compression path read a content entry's
`parts` and iterate it without type guards:
```python
# _has_non_text_parts
parts = content.get("parts", [])
for part in parts: ...
# _rebuild_gemini_contents
had_text = any("text" in p for p in content.get("parts", []))
# _gemini_contents_to_messages
parts = content.get("parts", [])
text_parts = [p.get("text", "") for p in parts if "text" in p]
```
`parts` is request-controlled and `.get("parts", [])` only falls back
when the key is absent, so:
- a present-but-null `parts` returns `None`, and `for part in None` /
`any(... for p in None)` raises `TypeError`;
- a list carrying a bare string (a client that treats `parts` as a
string array) makes `p.get("text", "")` raise `AttributeError`, while
`"text" in p` silently does substring matching first;
- a null element in the list crashes the same way.
Any of these 500s the request on the compression path, on data that
parsed as valid JSON.
## Fix
Route all three helpers through a shared `_dict_parts(content)` that
returns the dict entries of `parts`, coercing a non-dict content or a
non-list `parts` to an empty list and dropping non-dict elements.
`_gemini_contents_to_messages` also reads `role` defensively for a
non-dict content entry. Conversion now degrades gracefully (the
malformed part contributes nothing) instead of raising. Well-formed
requests are unchanged.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/handlers/gemini.py`: add `_dict_parts`; use it in
`_has_non_text_parts`, `_rebuild_gemini_contents`, and
`_gemini_contents_to_messages`; read `role` defensively for a non-dict
content entry.
- `tests/test_gemini_function_response_waste.py`: regressions for null
`parts`, bare-string part elements, a null part element,
`_has_non_text_parts` on malformed parts, and a non-dict content entry.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_gemini_function_response_waste.py -q
16 passed
# with the fix reverted, the new malformed-parts tests fail with
# TypeError: 'NoneType' object is not iterable (and AttributeError on string parts)
$ uvx ruff@0.15.17 check headroom/proxy/handlers/gemini.py tests/test_gemini_function_response_waste.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/gemini.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: built a real `HeadroomProxy` and called
`_gemini_contents_to_messages` / `_has_non_text_parts` with contents
carrying `parts: null`, `parts: ["bare string", {text}]`, `parts: [null,
{text}]`, and a non-dict content entry; then reverted `gemini.py` and
re-ran.
- Observed result: with the fix each malformed shape converts without
raising and the valid text part is still emitted (`[{"role": "user",
"content": "kept"}]`); with the fix reverted the null-`parts` and
null-element cases raise `TypeError: 'NoneType' object is not iterable`
and the string-element case raises `AttributeError: 'str' object has no
attribute 'get'`. Ran against the actual module via
`tests/test_gemini_function_response_waste.py`.
- Not tested: a live Gemini request with malformed `parts` routed
through the full proxy compression pipeline end to end.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
|
||
|
|
f0975b8de0
|
docs: add troubleshooting entry for uv build cache errors (#2490)
## Description Adds a troubleshooting section for a uv build error macOS users hit installing headroom-ai via uv: `src does not appear to be a Python project` (typically surfacing on `litellm` or `cryptography`) or `Unknown wheel data type: .DS_Store`. Root cause is uv build/wheel cache corruption on the user's machine, not a Headroom dependency pin. Also cross-references the existing `ast-grep-cli>=0.30.0,!=0.44.1` pin, which already excludes the compromised 0.44.1 build reported in the same issue. Closes #2476 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added "uv build errors: 'src does not appear to be a Python project'" subsection under Installation Issues in `docs/content/docs/troubleshooting.mdx`, with symptom, cause, and `uv cache clean` fix. - Cross-referenced the already-shipped `ast-grep-cli` version pin for the 0.44.1 supply-chain issue. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text N/A — docs-only change, no code paths touched. No pytest/ruff/mypy relevant. ``` ## Real Behavior Proof - Environment: N/A — markdown documentation edit only, no runtime behavior changed. - Exact command / steps: Read the modified `docs/content/docs/troubleshooting.mdx` section against the rendered structure of adjacent entries (Windows Defender / ast-grep-cli section) to confirm heading level, code fences, and link formatting match. - Observed result: New subsection renders consistently with surrounding Installation Issues entries (same `###` heading depth, Symptom/Cause/Fix structure, fenced code blocks). - Not tested: Live docs site build/preview (`cd docs && npm run dev`) was not run in this environment. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas — N/A, prose docs - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works — N/A, docs-only - [ ] New and existing unit tests pass locally with my changes — N/A, docs-only - [x] I did **not** edit `CHANGELOG.md` ## Screenshots (if applicable) N/A ## Additional Notes Docs-only change; no source code touched. `docs && npm run dev` not run locally in this environment — flagging for maintainer to preview if desired before merge. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
2195ba7d91
|
fix(proxy/openai): don't record Codex WS savings without input accounting (#2493)
## Description
On the Codex WS Responses path (`handle_openai_responses_ws`),
`tokens_saved` accumulates at compression time (our own token count),
while input tokens only arrive with a usage frame on
`response.completed`. A turn that is compressed but never completes —
cancelled mid-response (Esc in Codex), or an upstream error before the
usage frame — records `tokens_saved > 0` with `input_tokens == 0`
through the outcome funnel.
That writes a savings-with-zero-spend checkpoint into the savings
tracker: `compression_savings_usd` advances while `total_input_tokens` /
`total_input_cost_usd` stay flat. `/stats-history` then serves daily
buckets with `compression_savings_usd_delta > 0` and
`total_input_tokens_delta == 0`, which savings dashboards flag as a
data-integrity anomaly ("graph shows compression savings but zero tokens
spent on recent day(s)").
Both WS record sites have the hazard:
- the per-turn metrics closure (`_record_ws_response_metrics`) records
per-field-clamped deltas, so a usage-less turn contributes a
savings-only outcome;
- the session-end residual flush records `residual_tokens_saved` with
`residual_input_tokens` possibly 0.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/openai.py`:
- New module-level pure helper `_deferrable_savings_delta(input_delta,
saved_delta)` — returns 0 when `saved_delta > 0` with `input_delta <=
0`, passes everything else through unchanged.
- Per-turn metrics closure: gate `saved_delta` through the helper, and
advance `ws_recorded_tokens_saved_total += saved_delta` (previously `=
tokens_saved`) so deferred savings stay pending and ride along with the
next usage-carrying turn instead of being silently dropped.
- Session-end residual flush: gate `residual_tokens_saved` through the
same helper — savings that never saw a usage frame by session close are
dropped rather than recorded against zero spend (the spend for those
turns is genuinely unknown).
- `tests/test_codex_ws_savings_deferral.py`: truth-table test for the
helper; a bookkeeping walk asserting deferred savings land with the next
usage-carrying turn; and a source-level regression guard for the
closure-internal wiring (same idiom as
`test_codex_ws_compression_scheduler.py`, since the WS closures have no
unit harness yet).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed (real-behavior script below)
### Test Output
```text
$ uv run --frozen --extra dev pytest tests/test_codex_ws_savings_deferral.py tests/test_openai_codex_ws_lifecycle.py tests/test_codex_ws_compression_scheduler.py tests/test_openai_codex_ws_timings.py tests/test_proxy_savings_history.py
83 passed, 1 skipped (pre-existing pending-harness skip)
$ ruff check headroom/proxy/handlers/openai.py tests/test_codex_ws_savings_deferral.py
All checks passed!
$ uv run --frozen --extra dev mypy headroom/proxy/handlers/openai.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: macOS arm64 (Darwin 24.6.0), Python 3.12, this branch
checked out in the repo, run via `uv run --frozen python`.
- Exact command / steps: `uv run --frozen python rbp_demo.py` — a
real-behavior script exercising the REAL `SavingsTracker` (persistence +
`/stats-history` rollup via `history_response()`) and the REAL
`_deferrable_savings_delta` from this branch, no mocks. Scenario: turn 1
compressed (1200 saved) then cancelled before its usage frame (recorded
on day 1), turn 2 compressed (600 more) and completed with
`input_tokens=40000` (day 2); "BEFORE" records what the unfixed handler
emitted, "AFTER" walks the fixed bookkeeping. Additionally, a real
production `~/.headroom/proxy_savings.json` (5000 checkpoints, live
proxy in daily Claude Code + Codex use) was scanned for consecutive
checkpoint pairs where `compression_savings_usd` grew while
`total_input_tokens` stayed flat — one such pair was present
(`provider=openai, model=gpt-5.4-mini`, a Codex WS turn), exactly the
shape this PR removes at the source.
- Observed result: the unfixed recording produces a day-1
`/stats-history` bucket with `compression_savings_usd_delta > 0` and
`total_input_tokens_delta == 0` (the flagged anomaly); the fixed
bookkeeping produces no such bucket and preserves the full 1800 tokens
of savings, paired with the usage-carrying turn. Full output:
```text
BEFORE (unfixed recording): [{'tokens_saved': 1200, 'compression_savings_usd_delta': 0.0009, 'total_input_tokens_delta': 0},
{'tokens_saved': 600, 'compression_savings_usd_delta': 0.00045, 'total_input_tokens_delta': 40000}]
AFTER (fixed recording): [{'tokens_saved': 1800, 'compression_savings_usd_delta': 0.00135, 'total_input_tokens_delta': 40000}]
desync bucket present before fix: True
desync bucket present after fix: False
total savings preserved after fix: True
```
- Not tested: a live end-to-end WS session against the real OpenAI
upstream with a mid-response cancel (needs a real Codex client +
billable upstream). The per-turn/residual closure wiring is covered by
the source-level regression guard instead, per the pending-harness note
in `test_codex_ws_compression_scheduler.py`.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A —
internal accounting fix, no user-facing docs affected)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from the Conventional Commit PR title (a CI guard
enforces this)
## Additional Notes
- Sessions that end on a cancelled turn under-report savings slightly
(the deferred savings are dropped at close because their spend is
genuinely unknown). This is the honest trade-off: the alternative —
recording savings against zero spend — is the desync this PR removes.
- `attempted_input_tokens` is intentionally not gated: a cancelled turn
still records its attempted delta, keeping funnel-drop visibility.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5a0a5a79cd
|
docs: sync Vercel docs with current code and add in-depth proxy config (#2475)
## Description Bring the published docs (headroom-docs.vercel.app) back in line with the current codebase. The docs described an older architecture, advertised user/community statistics the code no longer supports (the telemetry beacon was removed), shipped code samples that raise on import, and lacked an in-depth treatment of proxy-mode configuration. Docs-only change — no `headroom/` source touched. ## Type of Change - [x] Documentation update ## Changes Made - **Remove user/community stats.** The anonymous telemetry beacon was removed from the code and `HEADROOM_TELEMETRY` is now local-only, but the docs still advertised aggregate "instances worldwide" figures — which were hardcoded/fabricated. Deleted `community-savings.mdx` (+ nav entry), the community/live stat widgets and their components (`community-charts`, `community-stats-header`, `live-stats`, `stats`, `lib/telemetry`, and a second fabricated `LiveStats` in `marketing.tsx`), and the `## Production Telemetry` section in `benchmarks.mdx`. Reframed all telemetry wording as local-only. - **Correct the architecture docs.** Rewrote `architecture.mdx` to the real pipeline (interceptor → CacheAligner *off-by-default* → ContentRouter; Rust `_core`; CCR on by default). Dropped the removed 3-stage / Context Manager / RollingWindow model. Fixed `how-compression-works.mdx` (3-stage framing, dead LLMLingua reference, wrong compressor class names) and added an off-by-default note to `cache-optimization.mdx`. - **Fix broken code samples** (verified against source): `TextCompressor`→`TextCrusher` + real `SearchCompressorConfig` fields (`text-and-logs`), `MemoryCategory`→plain string (`memory`), `unload_tree_sitter` import path (`code-compression`). - **In-depth proxy configuration.** Added a "Configuration in depth" section to `proxy.mdx` (Kompress, CCR/lossless, file-read handling, reliability, tool-search/MCP, cost-aware routing, observability, security/networking, performance). Fixed the `HEADROOM_MODE` default (`token`→`cache`) in three pages and removed a duplicate `HEADROOM_TELEMETRY` row. - **Nav + links.** Un-orphaned `crewai`/`autogen` in the sidebar; normalized `chopratejas`→`headroomlabs-ai` repo/GHCR links (kept the real HF model id `chopratejas/technique-router`); `litellm-vertex`→`vertex_ai`. ## Testing - [ ] Unit tests pass (`pytest`) — N/A, no `headroom/` code changed - [ ] Linting passes (`ruff check .`) — N/A, no Python changed - [ ] Type checking passes (`mypy headroom`) — N/A, no Python changed - [x] Manual testing performed (static docs validation; output below) ### Test Output ```text -- dangling refs to deleted components/pages (expect empty) -- (none) -- meta.json valid -- pages: 60 | community-savings present: false | crewai: true | autogen: true -- Callout balance (open == close) -- docs/content/docs/proxy.mdx open=6 close=6 docs/content/docs/cache-optimization.mdx open=1 close=1 ``` ## Real Behavior Proof - Environment: docs are static MDX (Fumadocs/Next.js); no runtime behavior. Corrections were checked against `headroom/` source. - Exact command / steps: grepped for references to deleted components/pages; validated `meta.json` parses and no longer contains `community-savings`; confirmed `<Callout>` open/close balance and frontmatter on every edited page; verified every corrected API name/field/import against the source modules (`text_crusher.py`, `search_compressor.py`, `memory/__init__.py`, `code_compressor.py`). - Observed result: no dangling references; nav valid; balanced JSX; corrected code samples match the real importable API. - Not tested: full `next build` / `npm run types:check` — `docs/node_modules` is not installed in this environment. Recommend a Vercel preview deploy (or `cd docs && npm i && npm run types:check`) as the merge gate. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas — N/A (docs) - [x] I have made corresponding changes to the documentation — this *is* the documentation - [x] My changes generate no new warnings - [ ] I have added tests — N/A (docs-only) - [x] New and existing unit tests pass locally with my changes — N/A, no code changed - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - Docs-only; no `headroom/` package code touched, so the pytest/ruff/mypy items are N/A. - The full Next.js build was not run locally (deps not installed) — a Vercel preview is the recommended gate. - Org normalization assumes `headroomlabs-ai` is canonical (matches CI/GHCR + the newer docs). If `chopratejas/headroom` is still the canonical **public** repo, revert the `docs/lib/*.ts` + install/docker link changes. - Heads-up: a separate `docs` branch exists on the remote — if the Vercel docs site deploys from `docs` rather than `main`, retarget this PR there. |
||
|
|
961866ba7c
|
deps: bump the npm-minor-patch group across 3 directories with 7 updates (#2276)
Bumps the npm-minor-patch group with 6 updates in the /docs directory: | Package | From | To | | --- | --- | --- | | [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.11.1` | `16.11.5` | | [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.1.0` | `15.2.0` | | [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.11.1` | `16.11.5` | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.106.0` | `0.111.0` | | [openai](https://github.com/openai/openai-node) | `6.33.0` | `6.47.0` | | [postcss](https://github.com/postcss/postcss) | `8.5.16` | `8.5.19` | Bumps the npm-minor-patch group with 1 update in the /plugins/opencode directory: @opencode-ai/plugin. Bumps the npm-minor-patch group with 1 update in the /sdk/typescript directory: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript). Updates `fumadocs-core` from 16.11.1 to 16.11.5 <details> <summary>Commits</summary> <ul> <li><a href=" |
||
|
|
3266ed7641
|
deps: bump the cargo-minor-patch group with 10 updates (#2284)
Bumps the cargo-minor-patch group with 10 updates: | Package | From | To | | --- | --- | --- | | [clap](https://github.com/clap-rs/clap) | `4.6.1` | `4.6.2` | | [aws-sigv4](https://github.com/smithy-lang/smithy-rs) | `1.4.5` | `1.5.1` | | [aws-config](https://github.com/smithy-lang/smithy-rs) | `1.8.18` | `1.9.0` | | [regex](https://github.com/rust-lang/regex) | `1.12.4` | `1.13.1` | | [toml](https://github.com/toml-rs/toml) | `1.1.2+spec-1.1.0` | `1.1.3+spec-1.1.0` | | [fastembed](https://github.com/Anush008/fastembed-rs) | `5.17.2` | `5.17.3` | | [uuid](https://github.com/uuid-rs/uuid) | `1.23.4` | `1.24.0` | | [http-body-util](https://github.com/hyperium/http-body) | `0.1.3` | `0.1.4` | | [lru](https://github.com/jeromefroe/lru-rs) | `0.18.0` | `0.18.1` | | [cc](https://github.com/rust-lang/cc-rs) | `1.2.66` | `1.2.67` | Updates `clap` from 4.6.1 to 4.6.2 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/clap-rs/clap/releases">clap's releases</a>.</em></p> <blockquote> <h2>v4.6.2</h2> <h2>[4.6.2] - 2026-07-15</h2> <h3>Fixes</h3> <ul> <li><em>(help)</em> Say <code>alias</code> when there is only one</li> </ul> </blockquote> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/clap-rs/clap/blob/master/CHANGELOG.md">clap's changelog</a>.</em></p> <blockquote> <h2>[4.6.2] - 2026-07-15</h2> <h3>Fixes</h3> <ul> <li><em>(help)</em> Say <code>alias</code> when there is only one</li> </ul> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href=" |
||
|
|
1329ed7f1a
|
feat(proxy): make /v1/compress usable as a gateway/Kong sidecar (#2458)
## Description Makes the compression-only `POST /v1/compress` endpoint usable as a **network compression sidecar** behind an API gateway (Kong, LiteLLM, ...), and fixes a latent content-detector hang that silently zeroed compression on non-Windows hosts. Motivated by a LiteLLM-sidecar deployment whose team documented five build-time patches; this ports the ones that belong upstream, generalized so they cover any aliasing gateway (not just LiteLLM). Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **`lossy_inline` compress mode** (`config.mode="lossy_inline"`, alias `"lossless_then_lossy"`): lossless byte/data fold first, then Kompress the folded remainder, with `ccr_inject_marker=False` so every compressor emits **inline, marker-free** output — no `<<ccr:…>>` markers and no CCR store write, so the result is safe to forward straight to a provider with no retrieval round-trip. The mode inherits the deployment's `enable_kompress`. - **`HEADROOM_COMPRESS_ALLOW_REMOTE`** opt-in: drops the loopback dependency on the `/v1/compress` route **only** so an authorized in-network gateway can reach it. Default is unchanged (loopback-only); inbound `HEADROOM_PROXY_TOKEN` auth still applies. - **`HEADROOM_MODEL_ALIAS_MAP`** (gateway-agnostic, fail-soft): one shared resolver in `pricing/litellm_pricing.py` reduces a gateway-aliased model name (e.g. `claude-opus`) to a priced `litellm.model_cost` key, trying the mapped target as-is and with a `bedrock/` / `vertex_ai/` prefix stripped. `proxy/savings_tracker.py` now delegates to it, so the live (`/stats`) and persisted (`/stats-history`) dollar figures price identically. - **`get_context_limit`**: an operator-configured limit (`HEADROOM_MODEL_LIMITS` / `~/.headroom/models.json`) now wins **before** the dynamic LiteLLM lookup, so an aliased name no longer falls through to the 128K default and skews compression. - **fix(content_router): first-call detector watchdog on all platforms.** The native content detector can deadlock on first use (#575, previously flagged Windows-only). The watchdog was `win32`-only, so on macOS/Linux a first-use hang was unbounded → `_detect_content` never returned → the `/v1/compress` executor timeout fired → fail-open → **`tokens_before=0`, silent zero compression**. Now the native detector runs under the watchdog on the first call on every platform; once it returns it is marked verified and the direct fast path is used (zero steady-state overhead). A hang degrades to pure-Python detection with a clear warning. `win32` behavior is unchanged. - Thread `waste_signals` / `pipeline_timing` into the already-present `/v1/compress` outcome record so the guardrail path populates the dashboard panels like the forward-proxy paths. Deliberately **not** ported: the sidecar's LiteLLM-specific `GET /model/info` HTTP fetch (urllib/ssl/threading/TTL). Kong has no such endpoint; the static `HEADROOM_MODEL_ALIAS_MAP` covers any gateway with no network dependency on the pricing path. ## Testing - [x] Unit tests pass (targeted — see output) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check <changed files> All checks passed! $ mypy <changed source files> Success: no issues found in 6 source files $ pytest tests/test_gateway_sidecar_ports.py tests/test_proxy_compress_endpoint.py -q tests/test_gateway_sidecar_ports.py ........ [ 34%] tests/test_proxy_compress_endpoint.py ............... [100%] ============================= 23 passed in 20.62s ============================== ``` ## Real Behavior Proof - **Environment:** macOS (darwin/arm64), Python 3.12, `.venv`; Kompress offloaded to a Modal endpoint via `HEADROOM_KOMPRESS_ENDPOINT`. - **Exact command / steps:** posted typical tool-output payloads to `POST /v1/compress` (via the FastAPI `TestClient`, loopback) in both `default` and `lossy_inline` modes; separately reproduced the detector hang with `faulthandler.dump_traceback_later`. - **Observed result:** - Real savings through the endpoint (structural/lossless, Kompress off): **JSON 150 records 13,982→9,514 (32.0%)**, **logs 314 lines 12,240→9,549 (22.0%)**, **search 200 hits 5,231→3,471 (33.6%)**. `lossy_inline` emits **zero** CCR markers. - `faulthandler` pinned the pre-fix hang to `content_router.py:_detect_content` → native `_rust_detect`. With the fix, the first call degrades at the 5s watchdog with `"Native content detector hung … using pure-Python detection"` and compression proceeds (previously it hung and the endpoint returned `tokens_before=0`). - Modal Kompress warm latency measured ~0.8s/call; the learned pass compresses prose further (62→56 words on a sample). - **Not tested:** full `pytest` suite (ran the two affected test files only); the native-detector hang was reproduced on a local macOS/arm64 build — the fix's degrade path is verified, but a healthy-native CI Linux run should confirm the fast (verified) path there. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - The five-item context comes from a downstream LiteLLM sidecar's `PATCHES.md`; item #3 (record an outcome from the guardrail path) was already upstreamed — this PR only adds the missing `waste_signals`/`pipeline_timing` threading. Item #2 (observability read-only exemption when `HEADROOM_PROXY_TOKEN` is set) is not addressed here. - All new config is opt-in and fail-soft; with nothing set, behavior is byte-identical to today. |
||
|
|
f4070c44cb
|
fix(transforms/cross-turn-dedup): don't renumber-fold zero-padded line prefixes (#2369)
## Description
On an HTTP tool-output re-read, `cross_turn_dedup` folds a contiguous
span that
already appeared in an earlier block into a compact pointer, and when
the line
numbers shifted by a constant it carries the offset as a `delta` so the
original
bytes recover as `int(number) + delta`. The module states this renumber
path is
"strictly lossless" for UNPADDED numbers only.
`_LINENO_RE = ^(\d+)(:|\t)(.*)$` does not enforce the "unpadded"
restriction: `\d+`
also matches a LEADING-ZERO prefix. A timestamped log row such as
`08:00:01 ...`
is read as line number `8`, not as data, so a re-read shifted by a
constant (a
later window of the same hourly log) folds under a uniform delta.
Recovery then
renders `str(int("08") + 1)` = `"9"`, not `"09"`: the round-trip is not
byte-exact. This is a lossy (false-positive) fold in a module whose
stated
posture is to prefer false negatives (`CONTRIBUTING.md:129`,
`cross_turn_dedup.py:45-50`).
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/transforms/cross_turn_dedup.py`: restrict `_LINENO_RE` to
`[1-9]\d*`
so a leading-zero run stays non-numbered and can fold only on an EXACT
match
(delta 0), never under a lossy renumber. Real `grep -n` / `sed -n` / `rg
-n`
numbers never carry a leading zero, so the intended renumber-fold
feature is
unchanged. Added a comment stating why the character class is
load-bearing.
- `tests/test_cross_turn_dedup.py`: added a delta-aware reconstruction
helper and
three regression tests (the existing `_reconstruct` asserts delta is
absent, so
it never exercised the numbered path this bug lives on).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
Three named scenarios, one test each:
1. `test_zero_padded_prefix_not_folded_lossily`: a padded shifted
re-read is left
verbatim (`spans_folded == 0`). This fails on `main` (it folds under a
delta).
2. `test_unpadded_renumber_still_folds_and_recovers_exactly`: an
unpadded `grep -n`
read renumbered by `+5` still folds and reconstructs byte-exact (feature
guard).
3. `test_padded_content_exact_redisplay_still_folds`: the same padded
rows
re-displayed verbatim still fold with delta 0 (surgical-scope guard).
### Test Output
```text
--- ruff check ---
All checks passed!
--- ruff format --check ---
2 files already formatted
--- mypy ---
Success: no issues found in 1 source file
--- pytest: 3 new tests on the branch (fixed) ---
3 passed, 14 deselected
--- pytest: revert regex to \d+ (simulate main): the regression test must FAIL ---
1 failed
```
## Real Behavior Proof
- Environment: clean `python:3.12-slim` Docker, `PYTHONPATH` at the
source tree,
core deps installed by name (tiktoken, pydantic, litellm, click, rich,
opentelemetry-api, pyyaml, tomlkit), `ruff==0.15.17`, `mypy==1.20.2`.
- Exact command / steps: import the module and print provenance, then
run ruff,
ruff format, mypy on the two changed files, then `pytest` the three new
tests
on the branch, then revert only the regex to `\d+` and re-run the
regression
test.
- Observed result: module `cross_turn_dedup.py` (sha256
7ff204b65f40617d505895841aa1cfc1ee54bf63ffe6520198f0aa05a850aa8d), regex
now `^([1-9]\d*)(:|\t)(.*)$`; ruff, ruff format, mypy all green; branch
`3 passed`, reverted-regex main `1 failed`. Breakdown:
- `module: /src/headroom/transforms/cross_turn_dedup.py`
- `sha256:
7ff204b65f40617d505895841aa1cfc1ee54bf63ffe6520198f0aa05a850aa8d`
- `regex : ^([1-9]\d*)(:|\t)(.*)$`
- ruff, ruff format, mypy: all green (output above).
- Branch: `3 passed`. Reverted-regex main: the regression test `1
failed`.
- Not tested: the router-level and Rust-backed integration tests in this
file
(`test_apply_*`, `test_dedup_*`) need the compiled `headroom._core`
extension,
which is not built in this lightweight container; they are
`ModuleNotFoundError`
on both `main` and this branch here, so they were not exercised. The
change is a
pure-stdlib regex in a pure-stdlib function; the unit-level
`dedup_blocks` tests
above cover it directly. I also did not measure how often real-world
tool output
hits the leading-zero shifted shape; the argument is the module's own
strictly-lossless contract, not observed field frequency.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A: no
doc surface)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
## Additional Notes
Per `CONTRIBUTING.md` ("Bug or small fix -> Open a PR with repro +
test"), this
goes straight to a PR rather than an issue. One concern only; no
dependency or
generated-file changes.
|